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.
- package/README.md +668 -0
- package/dist/__tests__/config.test.js +56 -0
- package/dist/__tests__/config.test.js.map +1 -0
- package/dist/__tests__/integration.test.js +341 -0
- package/dist/__tests__/integration.test.js.map +1 -0
- package/dist/__tests__/oauth-flow.test.js +201 -0
- package/dist/__tests__/oauth-flow.test.js.map +1 -0
- package/dist/__tests__/server.test.js +271 -0
- package/dist/__tests__/server.test.js.map +1 -0
- package/dist/__tests__/storage.test.js +256 -0
- package/dist/__tests__/storage.test.js.map +1 -0
- package/dist/client/config.js +30 -0
- package/dist/client/config.js.map +1 -0
- package/dist/client/factory.js +16 -0
- package/dist/client/factory.js.map +1 -0
- package/dist/client/index.js +237 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/oauth-flow.js +73 -0
- package/dist/client/oauth-flow.js.map +1 -0
- package/dist/client/storage.js +237 -0
- package/dist/client/storage.js.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/server/callback.js +164 -0
- package/dist/server/callback.js.map +1 -0
- package/dist/server/index.js +8 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/templates.js +245 -0
- package/dist/server/templates.js.map +1 -0
- package/package.json +66 -0
- package/src/__tests__/config.test.ts +78 -0
- package/src/__tests__/integration.test.ts +398 -0
- package/src/__tests__/oauth-flow.test.ts +276 -0
- package/src/__tests__/server.test.ts +391 -0
- package/src/__tests__/storage.test.ts +329 -0
- package/src/client/config.ts +134 -0
- package/src/client/factory.ts +19 -0
- package/src/client/index.ts +361 -0
- package/src/client/oauth-flow.ts +115 -0
- package/src/client/storage.ts +335 -0
- package/src/index.ts +31 -0
- package/src/server/callback.ts +257 -0
- package/src/server/index.ts +21 -0
- 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
|
+
}
|