Skip to content

Commit 78c9c21

Browse files
committed
add state machine
1 parent 3ec4069 commit 78c9c21

File tree

1 file changed

+193
-0
lines changed

1 file changed

+193
-0
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { OAuthStep, AuthDebuggerState } from "./auth-types";
2+
import { DebugInspectorOAuthClientProvider } from "./auth";
3+
import {
4+
discoverOAuthMetadata,
5+
registerClient,
6+
startAuthorization,
7+
exchangeAuthorization,
8+
} from "@modelcontextprotocol/sdk/client/auth.js";
9+
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
10+
11+
export interface StateMachineContext {
12+
state: AuthDebuggerState;
13+
serverUrl: string;
14+
provider: DebugInspectorOAuthClientProvider;
15+
updateState: (updates: Partial<AuthDebuggerState>) => void;
16+
}
17+
18+
export interface StateTransition {
19+
canTransition: (context: StateMachineContext) => Promise<boolean>;
20+
execute: (context: StateMachineContext) => Promise<void>;
21+
nextStep: OAuthStep;
22+
}
23+
24+
// State machine transitions
25+
export const oauthTransitions: Record<OAuthStep, StateTransition> = {
26+
not_started: {
27+
canTransition: async () => true,
28+
execute: async (context) => {
29+
context.updateState({
30+
oauthStep: "metadata_discovery",
31+
statusMessage: null,
32+
latestError: null,
33+
});
34+
},
35+
nextStep: "metadata_discovery",
36+
},
37+
38+
metadata_discovery: {
39+
canTransition: async () => true,
40+
execute: async (context) => {
41+
const metadata = await discoverOAuthMetadata(context.serverUrl);
42+
if (!metadata) {
43+
throw new Error("Failed to discover OAuth metadata");
44+
}
45+
const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata);
46+
context.provider.saveServerMetadata(parsedMetadata);
47+
context.updateState({
48+
oauthMetadata: parsedMetadata,
49+
oauthStep: "client_registration",
50+
});
51+
},
52+
nextStep: "client_registration",
53+
},
54+
55+
client_registration: {
56+
canTransition: async (context) => !!context.state.oauthMetadata,
57+
execute: async (context) => {
58+
const metadata = context.state.oauthMetadata!;
59+
const clientMetadata = context.provider.clientMetadata;
60+
61+
// Add all supported scopes to client registration
62+
if (metadata.scopes_supported) {
63+
clientMetadata.scope = metadata.scopes_supported.join(" ");
64+
}
65+
66+
const fullInformation = await registerClient(context.serverUrl, {
67+
metadata,
68+
clientMetadata,
69+
});
70+
71+
context.provider.saveClientInformation(fullInformation);
72+
context.updateState({
73+
oauthClientInfo: fullInformation,
74+
oauthStep: "authorization_redirect",
75+
});
76+
},
77+
nextStep: "authorization_redirect",
78+
},
79+
80+
authorization_redirect: {
81+
canTransition: async (context) =>
82+
!!context.state.oauthMetadata && !!context.state.oauthClientInfo,
83+
execute: async (context) => {
84+
const metadata = context.state.oauthMetadata!;
85+
const clientInformation = context.state.oauthClientInfo!;
86+
87+
let scope: string | undefined = undefined;
88+
if (metadata.scopes_supported) {
89+
scope = metadata.scopes_supported.join(" ");
90+
}
91+
92+
const { authorizationUrl, codeVerifier } = await startAuthorization(
93+
context.serverUrl,
94+
{
95+
metadata,
96+
clientInformation,
97+
redirectUrl: context.provider.redirectUrl,
98+
scope,
99+
},
100+
);
101+
102+
context.provider.saveCodeVerifier(codeVerifier);
103+
context.updateState({
104+
authorizationUrl: authorizationUrl.toString(),
105+
oauthStep: "authorization_code",
106+
});
107+
},
108+
nextStep: "authorization_code",
109+
},
110+
111+
authorization_code: {
112+
canTransition: async () => true,
113+
execute: async (context) => {
114+
if (
115+
!context.state.authorizationCode ||
116+
context.state.authorizationCode.trim() === ""
117+
) {
118+
context.updateState({
119+
validationError: "You need to provide an authorization code",
120+
});
121+
// Don't advance if no code
122+
throw new Error("Authorization code required");
123+
}
124+
context.updateState({
125+
validationError: null,
126+
oauthStep: "token_request",
127+
});
128+
},
129+
nextStep: "token_request",
130+
},
131+
132+
token_request: {
133+
canTransition: async (context) => {
134+
return (
135+
!!context.state.authorizationCode &&
136+
!!context.provider.getServerMetadata() &&
137+
!!(await context.provider.clientInformation())
138+
);
139+
},
140+
execute: async (context) => {
141+
const codeVerifier = context.provider.codeVerifier();
142+
const metadata = context.provider.getServerMetadata()!;
143+
const clientInformation = (await context.provider.clientInformation())!;
144+
145+
const tokens = await exchangeAuthorization(context.serverUrl, {
146+
metadata,
147+
clientInformation,
148+
authorizationCode: context.state.authorizationCode,
149+
codeVerifier,
150+
redirectUri: context.provider.redirectUrl,
151+
});
152+
153+
context.provider.saveTokens(tokens);
154+
context.updateState({
155+
oauthTokens: tokens,
156+
oauthStep: "complete",
157+
});
158+
},
159+
nextStep: "complete",
160+
},
161+
162+
complete: {
163+
canTransition: () => false,
164+
execute: async () => {
165+
// No-op for complete state
166+
},
167+
nextStep: "complete",
168+
},
169+
};
170+
171+
export class OAuthStateMachine {
172+
constructor(
173+
private serverUrl: string,
174+
private updateState: (updates: Partial<AuthDebuggerState>) => void,
175+
) {}
176+
177+
async executeStep(state: AuthDebuggerState): Promise<void> {
178+
const provider = new DebugInspectorOAuthClientProvider(this.serverUrl);
179+
const context: StateMachineContext = {
180+
state,
181+
serverUrl: this.serverUrl,
182+
provider,
183+
updateState: this.updateState,
184+
};
185+
186+
const transition = oauthTransitions[state.oauthStep];
187+
if (!(await transition.canTransition(context))) {
188+
throw new Error(`Cannot transition from ${state.oauthStep}`);
189+
}
190+
191+
await transition.execute(context);
192+
}
193+
}

0 commit comments

Comments
 (0)