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,361 @@
1
+ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
2
+ import { AuthorizationServerMetadata } from '@modelcontextprotocol/sdk/shared/auth.js';
3
+ import type {
4
+ OAuthClientInformation,
5
+ OAuthClientInformationFull,
6
+ OAuthClientMetadata,
7
+ OAuthConfig,
8
+ OAuthTokens,
9
+ } from './config.js';
10
+ import {
11
+ DEFAULT_CLIENT_METADATA,
12
+ generateSessionId,
13
+ generateState,
14
+ } from './config.js';
15
+ import { areTokensExpired, refreshTokensWithRetry } from './oauth-flow.js';
16
+ import { MemoryStorage, OAuthStorage } from './storage.js';
17
+
18
+ /**
19
+ * Options for token refresh
20
+ */
21
+ interface TokenRefreshConfig {
22
+ maxRetries: number;
23
+ retryDelay: number;
24
+ }
25
+
26
+ /**
27
+ * MCP OAuth Client Provider implementation with automatic token refresh
28
+ */
29
+ interface ProcessedOAuthConfig
30
+ extends Omit<
31
+ Required<OAuthConfig>,
32
+ 'clientSecret' | 'tokens' | 'authorizationServerMetadata'
33
+ > {
34
+ clientSecret?: string;
35
+ tokens?: OAuthTokens;
36
+ clientMetadata: OAuthClientMetadata;
37
+ tokenRefresh: TokenRefreshConfig;
38
+ }
39
+
40
+ export class MCPOAuthClientProvider implements OAuthClientProvider {
41
+ private readonly config: ProcessedOAuthConfig;
42
+ private readonly oauthStorage: OAuthStorage;
43
+ private readonly sessionId: string;
44
+ private cachedClientInformation?: OAuthClientInformationFull;
45
+
46
+ public authorizationServerMetadata?: AuthorizationServerMetadata;
47
+
48
+ constructor(config: OAuthConfig) {
49
+ this.sessionId = config.sessionId ?? generateSessionId();
50
+ const storage = config.storage ?? new MemoryStorage();
51
+
52
+ // Set authorization server metadata if provided
53
+ if (config.authorizationServerMetadata) {
54
+ this.authorizationServerMetadata =
55
+ config.authorizationServerMetadata as AuthorizationServerMetadata;
56
+ }
57
+
58
+ this.oauthStorage = new OAuthStorage(storage, this.sessionId, {
59
+ staticClientInfo: config.clientId
60
+ ? {
61
+ client_id: config.clientId,
62
+ client_secret: config.clientSecret,
63
+ }
64
+ : undefined,
65
+ initialTokens: config.tokens,
66
+ });
67
+
68
+ // Merge with defaults
69
+ this.config = {
70
+ clientId: config.clientId ?? '',
71
+ clientSecret: config.clientSecret ?? undefined,
72
+ redirectUri: config.redirectUri,
73
+ scope: config.scope ?? 'read write',
74
+ sessionId: this.sessionId,
75
+ storage,
76
+ tokens: config.tokens,
77
+ clientMetadata: {
78
+ ...DEFAULT_CLIENT_METADATA,
79
+ ...(config.clientMetadata ?? {}),
80
+ redirect_uris: [config.redirectUri], // Always required
81
+ scope: config.scope ?? DEFAULT_CLIENT_METADATA.scope,
82
+ },
83
+ tokenRefresh: {
84
+ maxRetries: 3,
85
+ retryDelay: 1000,
86
+ ...(config.tokenRefresh ?? {}),
87
+ },
88
+ };
89
+ }
90
+
91
+ /**
92
+ * The URL to redirect the user agent to after authorization
93
+ */
94
+ get redirectUrl(): string | URL {
95
+ return this.config.redirectUri;
96
+ }
97
+
98
+ /**
99
+ * Metadata about this OAuth client
100
+ */
101
+ get clientMetadata(): OAuthClientMetadata {
102
+ return this.config.clientMetadata;
103
+ }
104
+
105
+ /**
106
+ * Returns a OAuth2 state parameter for CSRF protection
107
+ */
108
+ async state(): Promise<string> {
109
+ return generateState();
110
+ }
111
+
112
+ /**
113
+ * Loads information about this OAuth client
114
+ * Config credentials are initialized into storage, so all reads go through storage
115
+ */
116
+ async clientInformation(): Promise<OAuthClientInformation | undefined> {
117
+ return await this.oauthStorage.getClientInfo();
118
+ }
119
+
120
+ /**
121
+ * Saves client information after dynamic registration
122
+ */
123
+ async saveClientInformation(
124
+ clientInformation: OAuthClientInformationFull
125
+ ): Promise<void> {
126
+ this.cachedClientInformation = clientInformation;
127
+
128
+ await this.oauthStorage.saveClientInfo(clientInformation);
129
+ }
130
+
131
+ /**
132
+ * Loads any existing OAuth tokens for the current session
133
+ * Automatically refreshes tokens if they're expired or about to expire (within 5 minutes)
134
+ * Requires authorizationServerMetadata to be set (from auth flow) for automatic refresh
135
+ */
136
+ async tokens(): Promise<OAuthTokens | undefined> {
137
+ const currentTokens = await this.oauthStorage.getTokens();
138
+
139
+ if (!currentTokens) {
140
+ return undefined;
141
+ }
142
+
143
+ // Check if tokens are expired or about to expire (within 5 minutes)
144
+ const needsRefresh = areTokensExpired(currentTokens, undefined, 300);
145
+
146
+ // If tokens need refresh and we have the server metadata and refresh token, refresh automatically
147
+ if (
148
+ needsRefresh &&
149
+ currentTokens.refresh_token &&
150
+ this.authorizationServerMetadata?.token_endpoint
151
+ ) {
152
+ try {
153
+ // Use the token endpoint from authorization server metadata
154
+ const newTokens = await this.refreshTokens();
155
+
156
+ return newTokens;
157
+ } catch (error) {
158
+ // If refresh fails, return the current tokens and let the caller handle it
159
+ // This prevents breaking existing flows that might handle refresh differently
160
+ return currentTokens;
161
+ }
162
+ }
163
+
164
+ return currentTokens;
165
+ }
166
+
167
+ /**
168
+ * Refresh tokens using helper function
169
+ * Uses the token endpoint from authorizationServerMetadata
170
+ *
171
+ * @returns New OAuth tokens
172
+ * @throws Error if no authorization server metadata is available
173
+ */
174
+ async refreshTokens(): Promise<OAuthTokens> {
175
+ if (!this.authorizationServerMetadata?.token_endpoint) {
176
+ throw new Error(
177
+ 'No authorization server metadata available. Cannot refresh tokens without token_endpoint.'
178
+ );
179
+ }
180
+
181
+ const { maxRetries, retryDelay } = this.config.tokenRefresh;
182
+
183
+ const currentTokens = await this.oauthStorage.getTokens();
184
+
185
+ if (!currentTokens?.refresh_token) {
186
+ throw new Error('No refresh token available');
187
+ }
188
+
189
+ const clientInfo = await this.clientInformation();
190
+
191
+ if (!clientInfo) {
192
+ throw new Error('No client information available');
193
+ }
194
+
195
+ try {
196
+ // Use helper function for retry logic with token endpoint
197
+ const newTokens = await refreshTokensWithRetry(
198
+ this.authorizationServerMetadata.token_endpoint,
199
+ clientInfo,
200
+ currentTokens.refresh_token,
201
+ this.addClientAuthentication,
202
+ maxRetries,
203
+ retryDelay
204
+ );
205
+
206
+ // Save the new tokens
207
+ await this.oauthStorage.saveTokens(newTokens);
208
+
209
+ return newTokens;
210
+ } catch (error) {
211
+ // All retries failed, invalidate tokens
212
+ await this.invalidateCredentials('tokens');
213
+ throw error;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Loads any existing OAuth tokens for the current session (without auto-refresh)
219
+ * Use this if you want to check tokens without triggering a refresh
220
+ */
221
+ async getStoredTokens(): Promise<OAuthTokens | undefined> {
222
+ return this.oauthStorage.getTokens();
223
+ }
224
+
225
+ /**
226
+ * Stores new OAuth tokens for the current session
227
+ */
228
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
229
+ await this.oauthStorage.saveTokens(tokens);
230
+ }
231
+
232
+ /**
233
+ * Invoked to redirect the user agent to the given URL to begin the authorization flow
234
+ */
235
+ async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
236
+ // In a real implementation, this would open a browser or redirect the user
237
+ // For now, we'll let the caller handle the redirect by throwing an error with the URL
238
+
239
+ // In a Bun environment, we could automatically open the browser:
240
+ if (typeof Bun !== 'undefined') {
241
+ try {
242
+ await Bun.$`open ${authorizationUrl.toString()}`;
243
+
244
+ return;
245
+ } catch {
246
+ // Fallback if 'open' command is not available
247
+ }
248
+ }
249
+
250
+ // If we can't automatically open, throw an error with the URL for the caller to handle
251
+ throw new Error(`Please navigate to: ${authorizationUrl.toString()}`);
252
+ }
253
+
254
+ /**
255
+ * Saves a PKCE code verifier for the current session
256
+ */
257
+ async saveCodeVerifier(codeVerifier: string): Promise<void> {
258
+ await this.oauthStorage.saveCodeVerifier(codeVerifier);
259
+ }
260
+
261
+ /**
262
+ * Loads the PKCE code verifier for the current session
263
+ */
264
+ async codeVerifier(): Promise<string> {
265
+ const verifier = await this.oauthStorage.getCodeVerifier();
266
+
267
+ if (!verifier) {
268
+ throw new Error('No code verifier found for current session');
269
+ }
270
+
271
+ return verifier;
272
+ }
273
+
274
+ /**
275
+ * Adds custom client authentication to OAuth token requests
276
+ */
277
+ addClientAuthentication = (
278
+ headers: Headers,
279
+ params: URLSearchParams,
280
+ url: string | URL,
281
+ metadata?: AuthorizationServerMetadata
282
+ ): void => {
283
+ const clientInfo = this.cachedClientInformation;
284
+
285
+ this.authorizationServerMetadata = metadata;
286
+
287
+ if (!clientInfo) {
288
+ throw new Error('No client information available for authentication');
289
+ }
290
+
291
+ // Use client_secret_post method by default
292
+ params.set('client_id', clientInfo.client_id);
293
+
294
+ if (clientInfo.client_secret) {
295
+ params.set('client_secret', clientInfo.client_secret);
296
+ }
297
+ };
298
+
299
+ /**
300
+ * Validates the resource URL for OAuth requests
301
+ */
302
+ async validateResourceURL(
303
+ serverUrl: string | URL,
304
+ resource?: string
305
+ ): Promise<URL | undefined> {
306
+ // Simple validation - in a real implementation you might want more sophisticated logic
307
+ if (resource) {
308
+ try {
309
+ return new URL(resource);
310
+ } catch {
311
+ throw new Error(`Invalid resource URL: ${resource}`);
312
+ }
313
+ }
314
+
315
+ // Default to server URL if no specific resource is provided
316
+ return new URL(serverUrl);
317
+ }
318
+
319
+ /**
320
+ * Invalidates stored credentials based on the specified scope
321
+ */
322
+ async invalidateCredentials(
323
+ scope: 'all' | 'client' | 'tokens' | 'verifier'
324
+ ): Promise<void> {
325
+ switch (scope) {
326
+ case 'all':
327
+ await this.oauthStorage.clearAll();
328
+ break;
329
+ case 'client':
330
+ await this.oauthStorage.clearClientInfo();
331
+ break;
332
+ case 'tokens':
333
+ await this.oauthStorage.clearTokens();
334
+ break;
335
+ case 'verifier':
336
+ await this.oauthStorage.clearCodeVerifier();
337
+ break;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Get the current session ID
343
+ */
344
+ getSessionId(): string {
345
+ return this.sessionId;
346
+ }
347
+
348
+ /**
349
+ * Get the OAuth storage helper
350
+ */
351
+ getOAuthStorage(): OAuthStorage {
352
+ return this.oauthStorage;
353
+ }
354
+
355
+ /**
356
+ * Clear the current session
357
+ */
358
+ async clearSession(): Promise<void> {
359
+ await this.oauthStorage.clearSession();
360
+ }
361
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * OAuth Flow Helpers
3
+ *
4
+ * Pure utility functions for OAuth operations
5
+ */
6
+
7
+ import { refreshAuthorization } from '@modelcontextprotocol/sdk/client/auth.js';
8
+ import type {
9
+ AuthorizationServerMetadata,
10
+ OAuthClientInformation,
11
+ OAuthTokens,
12
+ } from '@modelcontextprotocol/sdk/shared/auth.js';
13
+
14
+ /**
15
+ * Check if tokens are expired or about to expire
16
+ *
17
+ * @param tokens - OAuth tokens to check
18
+ * @param tokenExpiryTime - Tracked expiry timestamp (if available)
19
+ * @param bufferSeconds - Number of seconds before expiry to consider expired (default: 300 = 5 minutes)
20
+ * @returns true if tokens are expired or will expire soon
21
+ */
22
+ export function areTokensExpired(
23
+ tokens: OAuthTokens | undefined,
24
+ tokenExpiryTime?: number,
25
+ bufferSeconds = 300
26
+ ): boolean {
27
+ if (!tokens) {
28
+ return true;
29
+ }
30
+
31
+ // If we have a tracked expiry time, use it (most accurate)
32
+ if (tokenExpiryTime) {
33
+ const now = Date.now();
34
+
35
+ return now >= tokenExpiryTime - bufferSeconds * 1000;
36
+ }
37
+
38
+ // If no expiry info, assume tokens are valid
39
+ if (tokens.expires_in === undefined) {
40
+ return false;
41
+ }
42
+
43
+ // Check if tokens will expire within the buffer period
44
+ // Note: expires_in is now derived from stored expires_at, so it's always accurate
45
+ return tokens.expires_in <= bufferSeconds;
46
+ }
47
+
48
+ /**
49
+ * Refresh OAuth tokens with retry logic
50
+ *
51
+ * @param serverUrl - Authorization server URL
52
+ * @param clientInfo - Client information
53
+ * @param refreshToken - Current refresh token
54
+ * @param addClientAuth - Client authentication function
55
+ * @param maxRetries - Maximum retry attempts (default: 3)
56
+ * @param retryDelay - Base delay between retries in ms (default: 1000)
57
+ * @param fetchFn - Optional custom fetch function
58
+ * @returns New OAuth tokens
59
+ * @throws Error if refresh fails after all retries
60
+ */
61
+ export async function refreshTokensWithRetry(
62
+ serverUrl: string | URL,
63
+ clientInfo: OAuthClientInformation,
64
+ refreshToken: string,
65
+ addClientAuth?: (
66
+ headers: Headers,
67
+ params: URLSearchParams,
68
+ url: string | URL,
69
+ metadata?: AuthorizationServerMetadata
70
+ ) => void | Promise<void>,
71
+ maxRetries = 3,
72
+ retryDelay = 1000,
73
+ fetchFn?: typeof fetch
74
+ ): Promise<OAuthTokens> {
75
+ let lastError: Error | undefined;
76
+
77
+ /* eslint-disable no-await-in-loop */
78
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
79
+ try {
80
+ // Attempt token refresh using SDK function
81
+ const newTokens = await refreshAuthorization(serverUrl, {
82
+ clientInformation: clientInfo,
83
+ refreshToken,
84
+ addClientAuthentication: addClientAuth,
85
+ fetchFn,
86
+ });
87
+
88
+ return newTokens;
89
+ } catch (error) {
90
+ lastError = error as Error;
91
+
92
+ // If this isn't the last attempt, wait before retrying
93
+ if (attempt < maxRetries - 1) {
94
+ await new Promise(resolve =>
95
+ setTimeout(resolve, retryDelay * (attempt + 1))
96
+ );
97
+ }
98
+ }
99
+ }
100
+ /* eslint-enable no-await-in-loop */
101
+
102
+ throw new Error(
103
+ `Token refresh failed after ${maxRetries} attempts: ${lastError?.message || 'Unknown error'}`
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Calculate token expiry timestamp
109
+ *
110
+ * @param expiresIn - Token lifetime in seconds
111
+ * @returns Expiry timestamp in milliseconds
112
+ */
113
+ export function calculateTokenExpiry(expiresIn: number): number {
114
+ return Date.now() + expiresIn * 1000;
115
+ }