mcp-oauth-provider 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +668 -0
  2. package/dist/__tests__/config.test.js +56 -0
  3. package/dist/__tests__/config.test.js.map +1 -0
  4. package/dist/__tests__/integration.test.js +341 -0
  5. package/dist/__tests__/integration.test.js.map +1 -0
  6. package/dist/__tests__/oauth-flow.test.js +201 -0
  7. package/dist/__tests__/oauth-flow.test.js.map +1 -0
  8. package/dist/__tests__/server.test.js +271 -0
  9. package/dist/__tests__/server.test.js.map +1 -0
  10. package/dist/__tests__/storage.test.js +256 -0
  11. package/dist/__tests__/storage.test.js.map +1 -0
  12. package/dist/client/config.js +30 -0
  13. package/dist/client/config.js.map +1 -0
  14. package/dist/client/factory.js +16 -0
  15. package/dist/client/factory.js.map +1 -0
  16. package/dist/client/index.js +237 -0
  17. package/dist/client/index.js.map +1 -0
  18. package/dist/client/oauth-flow.js +73 -0
  19. package/dist/client/oauth-flow.js.map +1 -0
  20. package/dist/client/storage.js +237 -0
  21. package/dist/client/storage.js.map +1 -0
  22. package/dist/index.js +12 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/server/callback.js +164 -0
  25. package/dist/server/callback.js.map +1 -0
  26. package/dist/server/index.js +8 -0
  27. package/dist/server/index.js.map +1 -0
  28. package/dist/server/templates.js +245 -0
  29. package/dist/server/templates.js.map +1 -0
  30. package/package.json +66 -0
  31. package/src/__tests__/config.test.ts +78 -0
  32. package/src/__tests__/integration.test.ts +398 -0
  33. package/src/__tests__/oauth-flow.test.ts +276 -0
  34. package/src/__tests__/server.test.ts +391 -0
  35. package/src/__tests__/storage.test.ts +329 -0
  36. package/src/client/config.ts +134 -0
  37. package/src/client/factory.ts +19 -0
  38. package/src/client/index.ts +361 -0
  39. package/src/client/oauth-flow.ts +115 -0
  40. package/src/client/storage.ts +335 -0
  41. package/src/index.ts +31 -0
  42. package/src/server/callback.ts +257 -0
  43. package/src/server/index.ts +21 -0
  44. package/src/server/templates.ts +271 -0
@@ -0,0 +1,335 @@
1
+ import type {
2
+ OAuthClientInformation,
3
+ OAuthClientInformationFull,
4
+ OAuthTokens,
5
+ StorageAdapter,
6
+ } from './config.js';
7
+
8
+ export type { StorageAdapter };
9
+
10
+ /**
11
+ * Internal token storage format with absolute expiry timestamp
12
+ */
13
+ interface StoredTokens extends Omit<OAuthTokens, 'expires_in'> {
14
+ /**
15
+ * Absolute timestamp (milliseconds) when the token expires
16
+ * This is derived from expires_in when tokens are saved
17
+ */
18
+ expires_at?: number;
19
+ }
20
+
21
+ /**
22
+ * Calculate expires_in from expires_at timestamp
23
+ */
24
+ function calculateExpiresIn(expiresAt: number | undefined): number | undefined {
25
+ if (!expiresAt) {
26
+ return undefined;
27
+ }
28
+
29
+ const now = Date.now();
30
+ const expiresInMs = expiresAt - now;
31
+ const expiresInSeconds = Math.floor(expiresInMs / 1000);
32
+
33
+ // Return 0 if already expired, otherwise return remaining seconds
34
+ return Math.max(0, expiresInSeconds);
35
+ }
36
+
37
+ /**
38
+ * Calculate expires_at from expires_in
39
+ */
40
+ function calculateExpiresAt(expiresIn: number | undefined): number {
41
+ return Date.now() + (expiresIn || 0) * 1000;
42
+ }
43
+
44
+ /**
45
+ * In-memory storage adapter for OAuth data
46
+ * Suitable for development and testing, but data is lost when process exits
47
+ */
48
+ export class MemoryStorage implements StorageAdapter {
49
+ private data = new Map<string, string>();
50
+
51
+ async get(key: string): Promise<string | undefined> {
52
+ return this.data.get(key);
53
+ }
54
+
55
+ async set(key: string, value: string): Promise<void> {
56
+ this.data.set(key, value);
57
+ }
58
+
59
+ async delete(key: string): Promise<void> {
60
+ this.data.delete(key);
61
+ }
62
+
63
+ /**
64
+ * Clear all data (useful for testing)
65
+ */
66
+ clear(): void {
67
+ this.data.clear();
68
+ }
69
+ }
70
+
71
+ /**
72
+ * File-based storage adapter for OAuth data
73
+ * Persists data to the filesystem for longer-term storage
74
+ */
75
+ export class FileStorage implements StorageAdapter {
76
+ private basePath: string;
77
+
78
+ constructor(basePath = './oauth-data') {
79
+ this.basePath = basePath;
80
+ }
81
+
82
+ private getFilePath(key: string): string {
83
+ // Sanitize key for filename
84
+ const sanitized = key.replace(/[^a-zA-Z0-9-_]/g, '_');
85
+
86
+ return `${this.basePath}/${sanitized}.json`;
87
+ }
88
+
89
+ private async ensureDirectory(): Promise<void> {
90
+ try {
91
+ await Bun.write(`${this.basePath}/.gitkeep`, '');
92
+ } catch {
93
+ // Directory creation will happen automatically with Bun.write
94
+ }
95
+ }
96
+
97
+ async get(key: string): Promise<string | undefined> {
98
+ try {
99
+ const file = Bun.file(this.getFilePath(key));
100
+ const exists = await file.exists();
101
+
102
+ if (!exists) {
103
+ return undefined;
104
+ }
105
+
106
+ return await file.text();
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ async set(key: string, value: string): Promise<void> {
113
+ await this.ensureDirectory();
114
+ await Bun.write(this.getFilePath(key), value);
115
+ }
116
+
117
+ async delete(key: string): Promise<void> {
118
+ try {
119
+ await Bun.$`rm -f ${this.getFilePath(key)}`;
120
+ } catch {
121
+ // File might not exist, ignore error
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Clear all data (useful for testing)
127
+ */
128
+ async clear(): Promise<void> {
129
+ try {
130
+ await Bun.$`rm -rf ${this.basePath}`;
131
+ } catch {
132
+ // Directory might not exist, ignore error
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Create a storage adapter based on the provided configuration
139
+ */
140
+ export function createStorageAdapter(
141
+ type?: 'memory' | 'file',
142
+ options?: { path?: string }
143
+ ): StorageAdapter {
144
+ switch (type) {
145
+ case 'file':
146
+ return new FileStorage(options?.path);
147
+ case 'memory':
148
+ default:
149
+ return new MemoryStorage();
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Options for initializing OAuthStorage
155
+ */
156
+ export interface OAuthStorageOptions {
157
+ /**
158
+ * Static client information (from config)
159
+ * If provided, will ALWAYS be returned (takes precedence over storage)
160
+ * This is because client credentials are like API keys - they don't change
161
+ */
162
+ staticClientInfo?: OAuthClientInformation;
163
+
164
+ /**
165
+ * Initial tokens (from config)
166
+ * If provided, will be stored ONCE on first access if storage is empty
167
+ * After that, storage takes precedence (tokens change over time)
168
+ */
169
+ initialTokens?: OAuthTokens;
170
+ }
171
+
172
+ /**
173
+ * Helper class to manage OAuth data with the simplified storage adapter
174
+ * Handles initialization from config and provides a unified storage interface
175
+ */
176
+ export class OAuthStorage {
177
+ private initialized = false;
178
+
179
+ constructor(
180
+ private storage: StorageAdapter,
181
+ private sessionId: string,
182
+ private options?: OAuthStorageOptions
183
+ ) {}
184
+
185
+ /**
186
+ * Initialize storage with config values if provided
187
+ * This ensures config tokens are written to storage on first use
188
+ */
189
+ private async initialize(): Promise<void> {
190
+ if (this.initialized) {
191
+ return;
192
+ }
193
+
194
+ this.initialized = true;
195
+
196
+ // Initialize tokens if provided and not already in storage
197
+ // (tokens are one-time initialization, storage takes over after that)
198
+ if (this.options?.initialTokens) {
199
+ const existing = await this.storage.get(`tokens:${this.sessionId}`);
200
+
201
+ if (!existing) {
202
+ // Convert expires_in to expires_at before storing
203
+ const storedTokens: StoredTokens = {
204
+ access_token: this.options.initialTokens.access_token,
205
+ token_type: this.options.initialTokens.token_type,
206
+ refresh_token: this.options.initialTokens.refresh_token,
207
+ scope: this.options.initialTokens.scope,
208
+ };
209
+
210
+ // Only set expires_at if expires_in is provided
211
+ if (this.options.initialTokens.expires_in !== undefined) {
212
+ storedTokens.expires_at = calculateExpiresAt(
213
+ this.options.initialTokens.expires_in
214
+ );
215
+ }
216
+
217
+ await this.storage.set(
218
+ `tokens:${this.sessionId}`,
219
+ JSON.stringify(storedTokens)
220
+ );
221
+ }
222
+ }
223
+ }
224
+
225
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
226
+ await this.initialize();
227
+
228
+ // Convert expires_in to expires_at for storage
229
+ const storedTokens: StoredTokens = {
230
+ access_token: tokens.access_token,
231
+ token_type: tokens.token_type,
232
+ refresh_token: tokens.refresh_token,
233
+ scope: tokens.scope,
234
+ };
235
+
236
+ // Only set expires_at if expires_in is provided
237
+ if (tokens.expires_in !== undefined) {
238
+ storedTokens.expires_at = calculateExpiresAt(tokens.expires_in);
239
+ }
240
+
241
+ await this.storage.set(
242
+ `tokens:${this.sessionId}`,
243
+ JSON.stringify(storedTokens)
244
+ );
245
+ }
246
+
247
+ async getTokens(): Promise<OAuthTokens | undefined> {
248
+ await this.initialize();
249
+ const data = await this.storage.get(`tokens:${this.sessionId}`);
250
+
251
+ if (!data) {
252
+ return undefined;
253
+ }
254
+
255
+ const storedTokens: StoredTokens = JSON.parse(data);
256
+
257
+ // Convert expires_at back to expires_in for the OAuthTokens interface
258
+ const tokens: OAuthTokens = {
259
+ access_token: storedTokens.access_token,
260
+ token_type: storedTokens.token_type,
261
+ };
262
+
263
+ // Only include optional fields if they exist
264
+ if (storedTokens.refresh_token !== undefined) {
265
+ tokens.refresh_token = storedTokens.refresh_token;
266
+ }
267
+ if (storedTokens.scope !== undefined) {
268
+ tokens.scope = storedTokens.scope;
269
+ }
270
+ if (storedTokens.expires_at !== undefined) {
271
+ tokens.expires_in = calculateExpiresIn(storedTokens.expires_at);
272
+ }
273
+
274
+ return tokens;
275
+ }
276
+
277
+ async clearTokens(): Promise<void> {
278
+ await this.initialize();
279
+ await this.storage.delete(`tokens:${this.sessionId}`);
280
+ }
281
+
282
+ async saveClientInfo(clientInfo: OAuthClientInformationFull): Promise<void> {
283
+ await this.initialize();
284
+ await this.storage.set('client_info', JSON.stringify(clientInfo));
285
+ }
286
+
287
+ async getClientInfo(): Promise<OAuthClientInformation | undefined> {
288
+ await this.initialize();
289
+
290
+ // Static client info from config ALWAYS takes precedence
291
+ // (client credentials are like API keys - they don't change)
292
+ if (this.options?.staticClientInfo?.client_id) {
293
+ return this.options.staticClientInfo;
294
+ }
295
+
296
+ const data = await this.storage.get('client_info');
297
+
298
+ return data ? JSON.parse(data) : undefined;
299
+ }
300
+
301
+ async clearClientInfo(): Promise<void> {
302
+ await this.initialize();
303
+ await this.storage.delete('client_info');
304
+ }
305
+
306
+ async saveCodeVerifier(verifier: string): Promise<void> {
307
+ await this.initialize();
308
+ await this.storage.set(`verifier:${this.sessionId}`, verifier);
309
+ }
310
+
311
+ async getCodeVerifier(): Promise<string | undefined> {
312
+ await this.initialize();
313
+
314
+ return this.storage.get(`verifier:${this.sessionId}`);
315
+ }
316
+
317
+ async clearCodeVerifier(): Promise<void> {
318
+ await this.initialize();
319
+ await this.storage.delete(`verifier:${this.sessionId}`);
320
+ }
321
+
322
+ async clearSession(): Promise<void> {
323
+ await this.initialize();
324
+ await Promise.all([this.clearTokens(), this.clearCodeVerifier()]);
325
+ }
326
+
327
+ async clearAll(): Promise<void> {
328
+ await this.initialize();
329
+ await Promise.all([
330
+ this.clearTokens(),
331
+ this.clearCodeVerifier(),
332
+ this.clearClientInfo(),
333
+ ]);
334
+ }
335
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * MCP OAuth Provider
3
+ *
4
+ * OAuth client provider implementation for the Model Context Protocol (MCP)
5
+ */
6
+
7
+ export { MCPOAuthClientProvider } from './client/index.js';
8
+
9
+ export {
10
+ createStorageAdapter,
11
+ FileStorage,
12
+ MemoryStorage,
13
+ OAuthStorage,
14
+ type StorageAdapter,
15
+ } from './client/storage.js';
16
+
17
+ export {
18
+ DEFAULT_CLIENT_METADATA,
19
+ generateSessionId,
20
+ generateState,
21
+ type OAuthClientInformation,
22
+ type OAuthClientInformationFull,
23
+ type OAuthClientMetadata,
24
+ type OAuthConfig,
25
+ type OAuthTokens,
26
+ } from './client/config.js';
27
+
28
+ /**
29
+ * Factory function to create an OAuth client provider
30
+ */
31
+ export { createOAuthProvider } from './client/factory';
@@ -0,0 +1,257 @@
1
+ import { renderErrorPage, renderSuccessPage } from './templates.js';
2
+
3
+ /**
4
+ * OAuth callback result interface
5
+ */
6
+ export interface CallbackResult {
7
+ /** Authorization code returned by OAuth provider */
8
+ code?: string;
9
+ /** State parameter for CSRF protection */
10
+ state?: string;
11
+ /** OAuth error code (e.g., 'access_denied', 'invalid_request') */
12
+ error?: string;
13
+ /** Human-readable error description */
14
+ error_description?: string;
15
+ /** URI with additional error information */
16
+ error_uri?: string;
17
+ /** Additional query parameters from OAuth provider */
18
+ [key: string]: string | undefined;
19
+ }
20
+
21
+ /**
22
+ * Configuration options for the OAuth callback server
23
+ */
24
+ export interface CallbackServerOptions {
25
+ /** Port number to bind the server to */
26
+ port: number;
27
+ /** Hostname to bind the server to (default: "localhost") */
28
+ hostname?: string;
29
+ /** Custom HTML content for successful authorization */
30
+ successHtml?: string;
31
+ /** Custom HTML template for error pages */
32
+ errorHtml?: string;
33
+ /** AbortSignal for cancelling the server operation */
34
+ signal?: AbortSignal;
35
+ /** Callback function called for each HTTP request */
36
+ onRequest?: (req: Request) => void;
37
+ }
38
+
39
+ /**
40
+ * OAuth callback server implementation using Bun
41
+ */
42
+ export class OAuthCallbackServer {
43
+ private server?: { stop: () => void };
44
+ private callbackListeners = new Map<
45
+ string,
46
+ {
47
+ resolve: (result: CallbackResult) => void;
48
+ reject: (error: Error) => void;
49
+ }
50
+ >();
51
+ private options: CallbackServerOptions | undefined;
52
+
53
+ /**
54
+ * Start the HTTP server
55
+ */
56
+ async start(options: CallbackServerOptions): Promise<void> {
57
+ this.options = options;
58
+
59
+ if (this.server) {
60
+ throw new Error('Server is already running');
61
+ }
62
+
63
+ // Handle abort signal
64
+ if (options.signal?.aborted) {
65
+ throw new Error('Operation aborted');
66
+ }
67
+
68
+ const abortHandler = () => {
69
+ this.stop().catch(() => {
70
+ // Ignore errors during cleanup
71
+ });
72
+ };
73
+
74
+ if (options.signal) {
75
+ options.signal.addEventListener('abort', abortHandler);
76
+ }
77
+
78
+ try {
79
+ this.server = Bun.serve({
80
+ port: options.port,
81
+ hostname: options.hostname || 'localhost',
82
+ fetch: request => this.handleRequest(request),
83
+ error: error => {
84
+ return new Response(`Server error: ${error.message}`, {
85
+ status: 500,
86
+ });
87
+ },
88
+ });
89
+
90
+ // eslint-disable-next-line no-console
91
+ console.log(
92
+ `OAuth callback server running on http://${options.hostname || 'localhost'}:${options.port}`
93
+ );
94
+ } catch (error) {
95
+ if (options.signal) {
96
+ options.signal.removeEventListener('abort', abortHandler);
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Handle incoming HTTP requests
104
+ */
105
+ private handleRequest(request: Request): Response {
106
+ this.options?.onRequest?.(request);
107
+
108
+ const url = new URL(request.url);
109
+ const listener = this.callbackListeners.get(url.pathname);
110
+
111
+ if (!listener) {
112
+ return new Response('Not Found', { status: 404 });
113
+ }
114
+
115
+ // Extract OAuth callback parameters from URL
116
+ const params: CallbackResult = {};
117
+
118
+ for (const [key, value] of url.searchParams.entries()) {
119
+ params[key] = value;
120
+ }
121
+
122
+ // Generate appropriate response
123
+ const hasError = Boolean(params.error);
124
+ const html = hasError
125
+ ? renderErrorPage(
126
+ params.error,
127
+ params.error_description,
128
+ params.error_uri,
129
+ this.options?.errorHtml
130
+ )
131
+ : renderSuccessPage(this.options?.successHtml);
132
+
133
+ // Resolve the waiting promise with params (including error params)
134
+ // The caller can check for params.error to determine if it was an OAuth error
135
+ listener.resolve(params);
136
+
137
+ // Return HTML response with appropriate status code
138
+ return new Response(html, {
139
+ status: hasError ? 400 : 200,
140
+ headers: {
141
+ 'Content-Type': 'text/html; charset=utf-8',
142
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
143
+ Pragma: 'no-cache',
144
+ Expires: '0',
145
+ },
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Wait for OAuth callback on the specified path
151
+ */
152
+ async waitForCallback(
153
+ path: string,
154
+ timeout: number
155
+ ): Promise<CallbackResult> {
156
+ if (!this.server) {
157
+ throw new Error('Server is not running');
158
+ }
159
+
160
+ try {
161
+ return await Promise.race([
162
+ // Promise resolved by handleRequest method
163
+ new Promise<CallbackResult>((resolve, reject) => {
164
+ this.callbackListeners.set(path, { resolve, reject });
165
+ }),
166
+ // Timeout promise
167
+ new Promise<CallbackResult>((_, reject) => {
168
+ setTimeout(() => {
169
+ reject(
170
+ new Error(
171
+ `OAuth callback timeout after ${timeout}ms waiting for ${path}`
172
+ )
173
+ );
174
+ }, timeout);
175
+ }),
176
+ ]);
177
+ } finally {
178
+ // Always clean up the listener
179
+ this.callbackListeners.delete(path);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Stop the server and clean up resources
185
+ */
186
+ async stop(): Promise<void> {
187
+ if (this.server) {
188
+ // Reject any pending promises
189
+ const error = new Error('Server stopped');
190
+
191
+ for (const listener of this.callbackListeners.values()) {
192
+ // Reject in a try-catch to prevent unhandled promise rejections
193
+ try {
194
+ listener.reject(error);
195
+ } catch {
196
+ // Ignore - the caller may have already handled or abandoned this promise
197
+ }
198
+ }
199
+
200
+ this.callbackListeners.clear();
201
+
202
+ // Stop the server
203
+ await this.server.stop();
204
+ this.server = undefined;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Check if server is running
210
+ */
211
+ isRunning(): boolean {
212
+ return Boolean(this.server);
213
+ }
214
+
215
+ /**
216
+ * Get server URL if running
217
+ */
218
+ getServerUrl(): string | undefined {
219
+ if (!this.server || !this.options) {
220
+ return undefined;
221
+ }
222
+
223
+ const { hostname = 'localhost', port } = this.options;
224
+
225
+ return `http://${hostname}:${port}`;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Create and start a temporary OAuth callback server
231
+ */
232
+ export async function createCallbackServer(
233
+ options: CallbackServerOptions
234
+ ): Promise<OAuthCallbackServer> {
235
+ const server = new OAuthCallbackServer();
236
+
237
+ await server.start(options);
238
+
239
+ return server;
240
+ }
241
+
242
+ /**
243
+ * Convenience function to start server, wait for callback, and stop server
244
+ */
245
+ export async function waitForOAuthCallback(
246
+ path: string,
247
+ options: CallbackServerOptions & { timeout?: number }
248
+ ): Promise<CallbackResult> {
249
+ const server = await createCallbackServer(options);
250
+ const timeout = options.timeout ?? 30000; // 30 second default
251
+
252
+ try {
253
+ return await server.waitForCallback(path, timeout);
254
+ } finally {
255
+ await server.stop();
256
+ }
257
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * OAuth Callback Server Module
3
+ *
4
+ * Provides HTTP server functionality for handling OAuth authorization callbacks
5
+ */
6
+
7
+ export {
8
+ createCallbackServer,
9
+ OAuthCallbackServer,
10
+ waitForOAuthCallback,
11
+ type CallbackResult,
12
+ type CallbackServerOptions,
13
+ } from './callback.js';
14
+
15
+ export {
16
+ ERROR_TEMPLATE,
17
+ renderErrorPage,
18
+ renderSuccessPage,
19
+ renderTemplate,
20
+ SUCCESS_TEMPLATE,
21
+ } from './templates.js';