Skip to content

Commit db51706

Browse files
committed
WIP dynamic client registration
1 parent c0ebe90 commit db51706

File tree

3 files changed

+380
-34
lines changed

3 files changed

+380
-34
lines changed

Diff for: src/client/auth.ts

+2-34
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import pkceChallenge from "pkce-challenge";
22
import { z } from "zod";
33
import { LATEST_PROTOCOL_VERSION } from "../types.js";
4+
import type { OAuthClientMetadata, OAuthClientInformation } from "../shared/auth.js";
5+
import { OAuthClientInformationSchema } from "../shared/auth.js";
46

57
export const OAuthMetadataSchema = z
68
.object({
@@ -43,43 +45,9 @@ export const OAuthTokensSchema = z
4345
})
4446
.strip();
4547

46-
/**
47-
* Client metadata schema according to RFC 7591 OAuth 2.0 Dynamic Client Registration
48-
*/
49-
export const OAuthClientMetadataSchema = z.object({
50-
redirect_uris: z.array(z.string()),
51-
token_endpoint_auth_method: z.string().optional(),
52-
grant_types: z.array(z.string()).optional(),
53-
response_types: z.array(z.string()).optional(),
54-
client_name: z.string().optional(),
55-
client_uri: z.string().optional(),
56-
logo_uri: z.string().optional(),
57-
scope: z.string().optional(),
58-
contacts: z.array(z.string()).optional(),
59-
tos_uri: z.string().optional(),
60-
policy_uri: z.string().optional(),
61-
jwks_uri: z.string().optional(),
62-
jwks: z.any().optional(),
63-
software_id: z.string().optional(),
64-
software_version: z.string().optional(),
65-
}).passthrough();
66-
67-
/**
68-
* Client information response schema according to RFC 7591
69-
*/
70-
export const OAuthClientInformationSchema = z.object({
71-
client_id: z.string(),
72-
client_secret: z.string().optional(),
73-
client_id_issued_at: z.number().optional(),
74-
client_secret_expires_at: z.number().optional(),
75-
}).passthrough();
76-
7748
export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
7849
export type OAuthTokens = z.infer<typeof OAuthTokensSchema>;
7950

80-
export type OAuthClientMetadata = z.infer<typeof OAuthClientMetadataSchema>;
81-
export type OAuthClientInformation = z.infer<typeof OAuthClientInformationSchema>;
82-
8351
/**
8452
* Implements an end-to-end OAuth client to be used with one MCP server.
8553
*

Diff for: src/server/auth.ts

+328
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import {
2+
OAuthClientInformation,
3+
OAuthClientMetadata,
4+
OAuthClientMetadataSchema,
5+
ClientRegistrationError
6+
} from "../shared/auth.js";
7+
8+
/**
9+
* OAuth 2.0 Client Registration Provider interface.
10+
* Implementations provide storage and lifecycle management for OAuth clients.
11+
*/
12+
export interface OAuthClientRegistrationProvider {
13+
/**
14+
* Store a new client registration.
15+
* @param metadata The client metadata to register
16+
* @returns The client information including the assigned client_id
17+
*/
18+
registerClient(metadata: OAuthClientMetadata): Promise<OAuthClientInformation>;
19+
20+
/**
21+
* Retrieve client information by client_id.
22+
* @param clientId The client_id to look up
23+
* @returns The client information or null if not found
24+
*/
25+
getClient(clientId: string): Promise<OAuthClientInformation | null>;
26+
27+
/**
28+
* Update an existing client registration.
29+
* @param clientId The client_id to update
30+
* @param metadata The updated client metadata
31+
* @returns The updated client information
32+
*/
33+
updateClient(clientId: string, metadata: OAuthClientMetadata): Promise<OAuthClientInformation>;
34+
35+
/**
36+
* Delete a client registration.
37+
* @param clientId The client_id to delete
38+
* @returns true if the client was deleted, false if not found
39+
*/
40+
deleteClient(clientId: string): Promise<boolean>;
41+
}
42+
43+
/**
44+
* In-memory implementation of OAuthClientRegistrationProvider.
45+
* Useful for development and testing.
46+
*/
47+
export class InMemoryClientRegistrationProvider implements OAuthClientRegistrationProvider {
48+
private clients: Map<string, {
49+
metadata: OAuthClientMetadata;
50+
info: OAuthClientInformation;
51+
}> = new Map();
52+
53+
private generateClientId(): string {
54+
return `client_${Math.random().toString(36).substring(2, 15)}`;
55+
}
56+
57+
async registerClient(metadata: OAuthClientMetadata): Promise<OAuthClientInformation> {
58+
// Generate a client_id and optional client_secret
59+
const clientId = this.generateClientId();
60+
const clientSecret = metadata.token_endpoint_auth_method === "none"
61+
? undefined
62+
: `secret_${Math.random().toString(36).substring(2, 15)}`;
63+
64+
const now = Math.floor(Date.now() / 1000);
65+
66+
const clientInfo: OAuthClientInformation = {
67+
client_id: clientId,
68+
client_id_issued_at: now,
69+
...clientSecret && {
70+
client_secret,
71+
client_secret_expires_at: 0 // Never expires
72+
}
73+
};
74+
75+
this.clients.set(clientId, {
76+
metadata,
77+
info: clientInfo
78+
});
79+
80+
return clientInfo;
81+
}
82+
83+
async getClient(clientId: string): Promise<OAuthClientInformation | null> {
84+
const client = this.clients.get(clientId);
85+
return client ? client.info : null;
86+
}
87+
88+
async updateClient(clientId: string, metadata: OAuthClientMetadata): Promise<OAuthClientInformation> {
89+
const client = this.clients.get(clientId);
90+
if (!client) {
91+
throw new Error(`Client ${clientId} not found`);
92+
}
93+
94+
// Update metadata but keep the same client_id and secret
95+
this.clients.set(clientId, {
96+
metadata,
97+
info: client.info
98+
});
99+
100+
return client.info;
101+
}
102+
103+
async deleteClient(clientId: string): Promise<boolean> {
104+
return this.clients.delete(clientId);
105+
}
106+
}
107+
108+
/**
109+
* RFC 7591 Client Registration options.
110+
*/
111+
export interface ClientRegistrationOptions {
112+
/**
113+
* The provider that handles client registration storage.
114+
*/
115+
provider: OAuthClientRegistrationProvider;
116+
117+
/**
118+
* Optional function to validate client metadata beyond the basic schema validation.
119+
* Return true to accept, or throw an error with details about the rejection.
120+
*/
121+
validateMetadata?: (metadata: OAuthClientMetadata) => Promise<boolean>;
122+
123+
/**
124+
* Initial access token validator function.
125+
* If provided, requests to the registration endpoint must include a valid token.
126+
* @param token The bearer token from the Authorization header
127+
* @returns true if the token is valid, false otherwise
128+
*/
129+
validateInitialAccessToken?: (token: string) => Promise<boolean>;
130+
}
131+
132+
133+
/**
134+
* Client registration configuration information for the .well-known/oauth-authorization-server endpoint.
135+
*/
136+
export interface RegistrationEndpointConfig {
137+
registration_endpoint: string;
138+
registration_endpoint_auth_methods_supported?: string[];
139+
require_initial_access_token?: boolean;
140+
}
141+
142+
/**
143+
* RFC 7591 handler for registering OAuth clients.
144+
* This implements the server-side logic for the Dynamic Client Registration Protocol.
145+
*/
146+
export class ClientRegistrationHandler {
147+
private options: ClientRegistrationOptions;
148+
149+
constructor(options: ClientRegistrationOptions) {
150+
this.options = options;
151+
}
152+
153+
/**
154+
* Handle a client registration request.
155+
*
156+
* @param metadata The client metadata from the request
157+
* @param authHeader Optional Authorization header value (for initial access token)
158+
* @returns Client information or error response
159+
*/
160+
async handleRegistration(
161+
metadata: unknown,
162+
authHeader?: string
163+
): Promise<OAuthClientInformation | ClientRegistrationError> {
164+
// Validate initial access token if required
165+
if (this.options.validateInitialAccessToken) {
166+
if (!authHeader) {
167+
return {
168+
error: "invalid_client",
169+
error_description: "Initial access token required"
170+
};
171+
}
172+
173+
const match = /^Bearer\s+(.+)$/.exec(authHeader);
174+
if (!match) {
175+
return {
176+
error: "invalid_client",
177+
error_description: "Invalid authorization header format, expected 'Bearer TOKEN'"
178+
};
179+
}
180+
181+
const token = match[1];
182+
const isValid = await this.options.validateInitialAccessToken(token);
183+
if (!isValid) {
184+
return {
185+
error: "invalid_token",
186+
error_description: "Invalid initial access token"
187+
};
188+
}
189+
}
190+
191+
// Validate metadata against the schema
192+
const result = OAuthClientMetadataSchema.safeParse(metadata);
193+
194+
if (!result.success) {
195+
return {
196+
error: "invalid_client_metadata",
197+
error_description: `Invalid client metadata: ${result.error.message}`
198+
};
199+
}
200+
201+
const validatedMetadata = result.data;
202+
203+
// Additional custom validation if provided
204+
if (this.options.validateMetadata) {
205+
try {
206+
await this.options.validateMetadata(validatedMetadata);
207+
} catch (error) {
208+
return {
209+
error: "invalid_client_metadata",
210+
error_description: error instanceof Error
211+
? error.message
212+
: "Client metadata validation failed"
213+
};
214+
}
215+
}
216+
217+
try {
218+
// Register the client
219+
const clientInfo = await this.options.provider.registerClient(validatedMetadata);
220+
return clientInfo;
221+
} catch (error) {
222+
return {
223+
error: "server_error",
224+
error_description: error instanceof Error
225+
? error.message
226+
: "Failed to register client"
227+
};
228+
}
229+
}
230+
231+
/**
232+
* Handle a client update request.
233+
*
234+
* @param clientId The client_id to update
235+
* @param metadata The updated client metadata
236+
* @param authHeader Authorization header value for client authentication
237+
* @returns Updated client information or error response
238+
*/
239+
async handleUpdate(
240+
clientId: string,
241+
metadata: unknown,
242+
_authHeader: string
243+
): Promise<OAuthClientInformation | ClientRegistrationError> {
244+
// TODO: Implement client authentication validation
245+
246+
// Validate metadata
247+
const result = OAuthClientMetadataSchema.safeParse(metadata);
248+
249+
if (!result.success) {
250+
return {
251+
error: "invalid_client_metadata",
252+
error_description: `Invalid client metadata: ${result.error.message}`
253+
};
254+
}
255+
256+
const validatedMetadata = result.data;
257+
258+
// Additional custom validation if provided
259+
if (this.options.validateMetadata) {
260+
try {
261+
await this.options.validateMetadata(validatedMetadata);
262+
} catch (error) {
263+
return {
264+
error: "invalid_client_metadata",
265+
error_description: error instanceof Error
266+
? error.message
267+
: "Client metadata validation failed"
268+
};
269+
}
270+
}
271+
272+
try {
273+
// Verify client exists first
274+
const existingClient = await this.options.provider.getClient(clientId);
275+
if (!existingClient) {
276+
return {
277+
error: "invalid_client",
278+
error_description: "Client not found"
279+
};
280+
}
281+
282+
// Update the client
283+
const clientInfo = await this.options.provider.updateClient(clientId, validatedMetadata);
284+
return clientInfo;
285+
} catch (error) {
286+
return {
287+
error: "server_error",
288+
error_description: error instanceof Error
289+
? error.message
290+
: "Failed to update client"
291+
};
292+
}
293+
}
294+
295+
/**
296+
* Handle a client deletion request.
297+
*
298+
* @param clientId The client_id to delete
299+
* @param authHeader Authorization header value for client authentication
300+
* @returns Success message or error response
301+
*/
302+
async handleDelete(
303+
clientId: string,
304+
_authHeader: string
305+
): Promise<{ success: true } | ClientRegistrationError> {
306+
// TODO: Implement client authentication validation
307+
308+
try {
309+
const success = await this.options.provider.deleteClient(clientId);
310+
311+
if (!success) {
312+
return {
313+
error: "invalid_client",
314+
error_description: "Client not found"
315+
};
316+
}
317+
318+
return { success: true };
319+
} catch (error) {
320+
return {
321+
error: "server_error",
322+
error_description: error instanceof Error
323+
? error.message
324+
: "Failed to delete client"
325+
};
326+
}
327+
}
328+
}

0 commit comments

Comments
 (0)