Merge branch 'main' into fix-port-numbers
This commit is contained in:
commit
eb190b637d
8 changed files with 4913 additions and 442 deletions
38
client/src/lib/auth-types.ts
Normal file
38
client/src/lib/auth-types.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
OAuthMetadata,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
// OAuth flow steps
|
||||
export type OAuthStep =
|
||||
| "metadata_discovery"
|
||||
| "client_registration"
|
||||
| "authorization_redirect"
|
||||
| "authorization_code"
|
||||
| "token_request"
|
||||
| "complete";
|
||||
|
||||
// Message types for inline feedback
|
||||
export type MessageType = "success" | "error" | "info";
|
||||
|
||||
export interface StatusMessage {
|
||||
type: MessageType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Single state interface for OAuth state
|
||||
export interface AuthDebuggerState {
|
||||
isInitiatingAuth: boolean;
|
||||
oauthTokens: OAuthTokens | null;
|
||||
loading: boolean;
|
||||
oauthStep: OAuthStep;
|
||||
oauthMetadata: OAuthMetadata | null;
|
||||
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
|
||||
authorizationUrl: string | null;
|
||||
authorizationCode: string;
|
||||
latestError: Error | null;
|
||||
statusMessage: StatusMessage | null;
|
||||
validationError: string | null;
|
||||
}
|
||||
|
|
@ -4,11 +4,13 @@ import {
|
|||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
OAuthTokensSchema,
|
||||
OAuthClientMetadata,
|
||||
OAuthMetadata,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
||||
|
||||
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
constructor(private serverUrl: string) {
|
||||
constructor(public serverUrl: string) {
|
||||
// Save the server URL to session storage
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
||||
}
|
||||
|
|
@ -17,7 +19,7 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
return window.location.origin + "/oauth/callback";
|
||||
}
|
||||
|
||||
get clientMetadata() {
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl],
|
||||
token_endpoint_auth_method: "none",
|
||||
|
|
@ -101,3 +103,38 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides debug URL and allows saving server OAuth metadata to
|
||||
// display in debug UI.
|
||||
export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider {
|
||||
get redirectUrl(): string {
|
||||
return `${window.location.origin}/oauth/callback/debug`;
|
||||
}
|
||||
|
||||
saveServerMetadata(metadata: OAuthMetadata) {
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.SERVER_METADATA,
|
||||
this.serverUrl,
|
||||
);
|
||||
sessionStorage.setItem(key, JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
getServerMetadata(): OAuthMetadata | null {
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.SERVER_METADATA,
|
||||
this.serverUrl,
|
||||
);
|
||||
const metadata = sessionStorage.getItem(key);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(metadata);
|
||||
}
|
||||
|
||||
clear() {
|
||||
super.clear();
|
||||
sessionStorage.removeItem(
|
||||
getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const SESSION_KEYS = {
|
|||
SERVER_URL: "mcp_server_url",
|
||||
TOKENS: "mcp_tokens",
|
||||
CLIENT_INFORMATION: "mcp_client_information",
|
||||
SERVER_METADATA: "mcp_server_metadata",
|
||||
} as const;
|
||||
|
||||
// Generate server-specific session storage keys
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
|||
}));
|
||||
|
||||
// Mock the toast hook
|
||||
jest.mock("@/hooks/use-toast", () => ({
|
||||
jest.mock("@/lib/hooks/useToast", () => ({
|
||||
useToast: () => ({
|
||||
toast: jest.fn(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|||
import {
|
||||
SSEClientTransport,
|
||||
SseError,
|
||||
SSEClientTransportOptions,
|
||||
} from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import {
|
||||
StreamableHTTPClientTransport,
|
||||
StreamableHTTPClientTransportOptions,
|
||||
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import {
|
||||
ClientNotification,
|
||||
ClientRequest,
|
||||
|
|
@ -27,7 +31,7 @@ import {
|
|||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useToast } from "@/lib/hooks/useToast";
|
||||
import { z } from "zod";
|
||||
import { ConnectionStatus } from "../constants";
|
||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
||||
|
|
@ -279,29 +283,6 @@ export function useConnection({
|
|||
setConnectionStatus("error-connecting-to-proxy");
|
||||
return;
|
||||
}
|
||||
let mcpProxyServerUrl;
|
||||
switch (transportType) {
|
||||
case "stdio":
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
|
||||
mcpProxyServerUrl.searchParams.append("command", command);
|
||||
mcpProxyServerUrl.searchParams.append("args", args);
|
||||
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
|
||||
break;
|
||||
|
||||
case "sse":
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
|
||||
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||
break;
|
||||
|
||||
case "streamable-http":
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
|
||||
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||
break;
|
||||
}
|
||||
(mcpProxyServerUrl as URL).searchParams.append(
|
||||
"transportType",
|
||||
transportType,
|
||||
);
|
||||
|
||||
try {
|
||||
// Inject auth manually instead of using SSEClientTransport, because we're
|
||||
|
|
@ -320,21 +301,82 @@ export function useConnection({
|
|||
}
|
||||
|
||||
// Create appropriate transport
|
||||
const transportOptions = {
|
||||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init: RequestInit | undefined,
|
||||
) => fetch(url, { ...init, headers }),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
},
|
||||
};
|
||||
let transportOptions:
|
||||
| StreamableHTTPClientTransportOptions
|
||||
| SSEClientTransportOptions;
|
||||
|
||||
let mcpProxyServerUrl;
|
||||
switch (transportType) {
|
||||
case "stdio":
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
|
||||
mcpProxyServerUrl.searchParams.append("command", command);
|
||||
mcpProxyServerUrl.searchParams.append("args", args);
|
||||
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
|
||||
transportOptions = {
|
||||
authProvider: serverAuthProvider,
|
||||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init: RequestInit | undefined,
|
||||
) => fetch(url, { ...init, headers }),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
||||
case "sse":
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
|
||||
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||
transportOptions = {
|
||||
authProvider: serverAuthProvider,
|
||||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init: RequestInit | undefined,
|
||||
) => fetch(url, { ...init, headers }),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
||||
case "streamable-http":
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
|
||||
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||
transportOptions = {
|
||||
authProvider: serverAuthProvider,
|
||||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init: RequestInit | undefined,
|
||||
) => fetch(url, { ...init, headers }),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
},
|
||||
// TODO these should be configurable...
|
||||
reconnectionOptions: {
|
||||
maxReconnectionDelay: 30000,
|
||||
initialReconnectionDelay: 1000,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
maxRetries: 2,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
(mcpProxyServerUrl as URL).searchParams.append(
|
||||
"transportType",
|
||||
transportType,
|
||||
);
|
||||
|
||||
const clientTransport =
|
||||
transportType === "streamable-http"
|
||||
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
|
||||
sessionId: undefined,
|
||||
...transportOptions,
|
||||
})
|
||||
: new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions);
|
||||
|
||||
|
|
|
|||
52
client/src/lib/hooks/useTheme.ts
Normal file
52
client/src/lib/hooks/useTheme.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
const useTheme = (): [Theme, (mode: Theme) => void] => {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const savedTheme = localStorage.getItem("theme") as Theme;
|
||||
return savedTheme || "system";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const darkModeMediaQuery = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
);
|
||||
const handleDarkModeChange = (e: MediaQueryListEvent) => {
|
||||
if (theme === "system") {
|
||||
updateDocumentTheme(e.matches ? "dark" : "light");
|
||||
}
|
||||
};
|
||||
|
||||
const updateDocumentTheme = (newTheme: "light" | "dark") => {
|
||||
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||
};
|
||||
|
||||
// Set initial theme based on current mode
|
||||
if (theme === "system") {
|
||||
updateDocumentTheme(darkModeMediaQuery.matches ? "dark" : "light");
|
||||
} else {
|
||||
updateDocumentTheme(theme);
|
||||
}
|
||||
|
||||
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
|
||||
|
||||
return () => {
|
||||
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
const setThemeWithSideEffect = useCallback((newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
if (newTheme !== "system") {
|
||||
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||
}
|
||||
}, []);
|
||||
return useMemo(
|
||||
() => [theme, setThemeWithSideEffect],
|
||||
[theme, setThemeWithSideEffect],
|
||||
);
|
||||
};
|
||||
|
||||
export default useTheme;
|
||||
191
client/src/lib/hooks/useToast.ts
Normal file
191
client/src/lib/hooks/useToast.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react";
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
const enum ActionType {
|
||||
ADD_TOAST = "ADD_TOAST",
|
||||
UPDATE_TOAST = "UPDATE_TOAST",
|
||||
DISMISS_TOAST = "DISMISS_TOAST",
|
||||
REMOVE_TOAST = "REMOVE_TOAST",
|
||||
}
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType.ADD_TOAST;
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType.UPDATE_TOAST;
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType.DISMISS_TOAST;
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType.REMOVE_TOAST;
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: ActionType.REMOVE_TOAST,
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case ActionType.ADD_TOAST:
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case ActionType.UPDATE_TOAST:
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
};
|
||||
|
||||
case ActionType.DISMISS_TOAST: {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
};
|
||||
}
|
||||
case ActionType.REMOVE_TOAST:
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: ActionType.UPDATE_TOAST,
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () =>
|
||||
dispatch({ type: ActionType.DISMISS_TOAST, toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: ActionType.ADD_TOAST,
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) =>
|
||||
dispatch({ type: ActionType.DISMISS_TOAST, toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
181
client/src/lib/oauth-state-machine.ts
Normal file
181
client/src/lib/oauth-state-machine.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { OAuthStep, AuthDebuggerState } from "./auth-types";
|
||||
import { DebugInspectorOAuthClientProvider } from "./auth";
|
||||
import {
|
||||
discoverOAuthMetadata,
|
||||
registerClient,
|
||||
startAuthorization,
|
||||
exchangeAuthorization,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
export interface StateMachineContext {
|
||||
state: AuthDebuggerState;
|
||||
serverUrl: string;
|
||||
provider: DebugInspectorOAuthClientProvider;
|
||||
updateState: (updates: Partial<AuthDebuggerState>) => void;
|
||||
}
|
||||
|
||||
export interface StateTransition {
|
||||
canTransition: (context: StateMachineContext) => Promise<boolean>;
|
||||
execute: (context: StateMachineContext) => Promise<void>;
|
||||
nextStep: OAuthStep;
|
||||
}
|
||||
|
||||
// State machine transitions
|
||||
export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
||||
metadata_discovery: {
|
||||
canTransition: async () => true,
|
||||
execute: async (context) => {
|
||||
const metadata = await discoverOAuthMetadata(context.serverUrl);
|
||||
if (!metadata) {
|
||||
throw new Error("Failed to discover OAuth metadata");
|
||||
}
|
||||
const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata);
|
||||
context.provider.saveServerMetadata(parsedMetadata);
|
||||
context.updateState({
|
||||
oauthMetadata: parsedMetadata,
|
||||
oauthStep: "client_registration",
|
||||
});
|
||||
},
|
||||
nextStep: "client_registration",
|
||||
},
|
||||
|
||||
client_registration: {
|
||||
canTransition: async (context) => !!context.state.oauthMetadata,
|
||||
execute: async (context) => {
|
||||
const metadata = context.state.oauthMetadata!;
|
||||
const clientMetadata = context.provider.clientMetadata;
|
||||
|
||||
// Add all supported scopes to client registration
|
||||
if (metadata.scopes_supported) {
|
||||
clientMetadata.scope = metadata.scopes_supported.join(" ");
|
||||
}
|
||||
|
||||
const fullInformation = await registerClient(context.serverUrl, {
|
||||
metadata,
|
||||
clientMetadata,
|
||||
});
|
||||
|
||||
context.provider.saveClientInformation(fullInformation);
|
||||
context.updateState({
|
||||
oauthClientInfo: fullInformation,
|
||||
oauthStep: "authorization_redirect",
|
||||
});
|
||||
},
|
||||
nextStep: "authorization_redirect",
|
||||
},
|
||||
|
||||
authorization_redirect: {
|
||||
canTransition: async (context) =>
|
||||
!!context.state.oauthMetadata && !!context.state.oauthClientInfo,
|
||||
execute: async (context) => {
|
||||
const metadata = context.state.oauthMetadata!;
|
||||
const clientInformation = context.state.oauthClientInfo!;
|
||||
|
||||
let scope: string | undefined = undefined;
|
||||
if (metadata.scopes_supported) {
|
||||
scope = metadata.scopes_supported.join(" ");
|
||||
}
|
||||
|
||||
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
||||
context.serverUrl,
|
||||
{
|
||||
metadata,
|
||||
clientInformation,
|
||||
redirectUrl: context.provider.redirectUrl,
|
||||
scope,
|
||||
},
|
||||
);
|
||||
|
||||
context.provider.saveCodeVerifier(codeVerifier);
|
||||
context.updateState({
|
||||
authorizationUrl: authorizationUrl.toString(),
|
||||
oauthStep: "authorization_code",
|
||||
});
|
||||
},
|
||||
nextStep: "authorization_code",
|
||||
},
|
||||
|
||||
authorization_code: {
|
||||
canTransition: async () => true,
|
||||
execute: async (context) => {
|
||||
if (
|
||||
!context.state.authorizationCode ||
|
||||
context.state.authorizationCode.trim() === ""
|
||||
) {
|
||||
context.updateState({
|
||||
validationError: "You need to provide an authorization code",
|
||||
});
|
||||
// Don't advance if no code
|
||||
throw new Error("Authorization code required");
|
||||
}
|
||||
context.updateState({
|
||||
validationError: null,
|
||||
oauthStep: "token_request",
|
||||
});
|
||||
},
|
||||
nextStep: "token_request",
|
||||
},
|
||||
|
||||
token_request: {
|
||||
canTransition: async (context) => {
|
||||
return (
|
||||
!!context.state.authorizationCode &&
|
||||
!!context.provider.getServerMetadata() &&
|
||||
!!(await context.provider.clientInformation())
|
||||
);
|
||||
},
|
||||
execute: async (context) => {
|
||||
const codeVerifier = context.provider.codeVerifier();
|
||||
const metadata = context.provider.getServerMetadata()!;
|
||||
const clientInformation = (await context.provider.clientInformation())!;
|
||||
|
||||
const tokens = await exchangeAuthorization(context.serverUrl, {
|
||||
metadata,
|
||||
clientInformation,
|
||||
authorizationCode: context.state.authorizationCode,
|
||||
codeVerifier,
|
||||
redirectUri: context.provider.redirectUrl,
|
||||
});
|
||||
|
||||
context.provider.saveTokens(tokens);
|
||||
context.updateState({
|
||||
oauthTokens: tokens,
|
||||
oauthStep: "complete",
|
||||
});
|
||||
},
|
||||
nextStep: "complete",
|
||||
},
|
||||
|
||||
complete: {
|
||||
canTransition: async () => false,
|
||||
execute: async () => {
|
||||
// No-op for complete state
|
||||
},
|
||||
nextStep: "complete",
|
||||
},
|
||||
};
|
||||
|
||||
export class OAuthStateMachine {
|
||||
constructor(
|
||||
private serverUrl: string,
|
||||
private updateState: (updates: Partial<AuthDebuggerState>) => void,
|
||||
) {}
|
||||
|
||||
async executeStep(state: AuthDebuggerState): Promise<void> {
|
||||
const provider = new DebugInspectorOAuthClientProvider(this.serverUrl);
|
||||
const context: StateMachineContext = {
|
||||
state,
|
||||
serverUrl: this.serverUrl,
|
||||
provider,
|
||||
updateState: this.updateState,
|
||||
};
|
||||
|
||||
const transition = oauthTransitions[state.oauthStep];
|
||||
if (!(await transition.canTransition(context))) {
|
||||
throw new Error(`Cannot transition from ${state.oauthStep}`);
|
||||
}
|
||||
|
||||
await transition.execute(context);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue