@barndoor-ai/sdk 0.2.0
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/.eslintignore +8 -0
- package/.eslintrc.cjs +102 -0
- package/.github/CODEOWNERS +4 -0
- package/.github/workflows/ci.yml +57 -0
- package/.prettierignore +6 -0
- package/.prettierrc +13 -0
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/RELEASE.md +203 -0
- package/examples/README.md +92 -0
- package/examples/basic-mcp-client.js +134 -0
- package/examples/openai-integration.js +137 -0
- package/jest.config.js +16 -0
- package/openapi.yaml +681 -0
- package/package.json +87 -0
- package/rollup.config.js +63 -0
- package/scripts/dump-core-files.js +161 -0
- package/scripts/dump-typescript-only.js +150 -0
- package/src/auth/index.ts +26 -0
- package/src/auth/pkce.ts +346 -0
- package/src/auth/store.ts +809 -0
- package/src/client.ts +512 -0
- package/src/config.ts +402 -0
- package/src/exceptions/index.ts +205 -0
- package/src/http/client.ts +272 -0
- package/src/index.ts +92 -0
- package/src/logging.ts +111 -0
- package/src/models/index.ts +156 -0
- package/src/quickstart.ts +358 -0
- package/src/version.ts +41 -0
- package/test/client.test.js +381 -0
- package/test/config.test.js +202 -0
- package/test/exceptions.test.js +142 -0
- package/test/integration.test.js +147 -0
- package/test/models.test.js +177 -0
- package/test/token-management.test.js +81 -0
- package/test/token-validation.test.js +104 -0
- package/tsconfig.json +61 -0
package/src/auth/pkce.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (Proof Key for Code Exchange) implementation for OAuth 2.0.
|
|
3
|
+
*
|
|
4
|
+
* This module provides PKCE functionality that mirrors the Python SDK's
|
|
5
|
+
* auth.py implementation, supporting secure OAuth flows in both browser
|
|
6
|
+
* and Node.js environments.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { OAuthError } from '../exceptions';
|
|
10
|
+
import { isBrowser, isNode } from '../config';
|
|
11
|
+
import { createScopedLogger } from '../logging';
|
|
12
|
+
import crypto from 'crypto';
|
|
13
|
+
import http from 'http';
|
|
14
|
+
import url from 'url';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* PKCE state data structure.
|
|
18
|
+
*/
|
|
19
|
+
export interface PKCEState {
|
|
20
|
+
/** Code verifier for PKCE flow */
|
|
21
|
+
codeVerifier: string;
|
|
22
|
+
/** Code challenge derived from verifier */
|
|
23
|
+
codeChallenge: string;
|
|
24
|
+
/** OAuth state parameter */
|
|
25
|
+
state: string;
|
|
26
|
+
/** Timestamp when state was created */
|
|
27
|
+
timestamp: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* PKCE Manager class to handle state per instance instead of globally.
|
|
32
|
+
* This prevents race conditions in browser environments with multiple parallel login flows.
|
|
33
|
+
*/
|
|
34
|
+
export class PKCEManager {
|
|
35
|
+
private _codeVerifier: string | null = null;
|
|
36
|
+
private _currentState: string | null = null;
|
|
37
|
+
private readonly _logger = createScopedLogger('pkce');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generate PKCE parameters and build authorization URL.
|
|
41
|
+
* @param params - Authorization parameters
|
|
42
|
+
* @returns Authorization URL
|
|
43
|
+
*/
|
|
44
|
+
public async buildAuthorizationUrl({
|
|
45
|
+
domain,
|
|
46
|
+
clientId,
|
|
47
|
+
redirectUri,
|
|
48
|
+
audience,
|
|
49
|
+
scope = 'openid profile email',
|
|
50
|
+
}: AuthorizationUrlParams): Promise<string> {
|
|
51
|
+
// Generate PKCE parameters
|
|
52
|
+
this._codeVerifier = generateRandomString(32);
|
|
53
|
+
const codeChallenge = base64URLEncode(await sha256(this._codeVerifier));
|
|
54
|
+
this._currentState = generateRandomString(16);
|
|
55
|
+
|
|
56
|
+
// Build authorization URL
|
|
57
|
+
const params = new URLSearchParams({
|
|
58
|
+
response_type: 'code',
|
|
59
|
+
client_id: clientId,
|
|
60
|
+
redirect_uri: redirectUri,
|
|
61
|
+
scope,
|
|
62
|
+
audience,
|
|
63
|
+
state: this._currentState,
|
|
64
|
+
code_challenge: codeChallenge,
|
|
65
|
+
code_challenge_method: 'S256',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const authUrl = `https://${domain}/authorize?${params.toString()}`;
|
|
69
|
+
return authUrl;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Exchange authorization code for tokens using stored PKCE state.
|
|
74
|
+
* @param params - Token exchange parameters
|
|
75
|
+
* @returns Token response
|
|
76
|
+
*/
|
|
77
|
+
public async exchangeCodeForToken({
|
|
78
|
+
domain,
|
|
79
|
+
clientId,
|
|
80
|
+
code,
|
|
81
|
+
redirectUri,
|
|
82
|
+
clientSecret,
|
|
83
|
+
}: TokenExchangeParams): Promise<unknown> {
|
|
84
|
+
const payload: Record<string, string> = {
|
|
85
|
+
grant_type: 'authorization_code',
|
|
86
|
+
client_id: clientId,
|
|
87
|
+
code,
|
|
88
|
+
redirect_uri: redirectUri,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Always add client_secret if provided (like Python SDK)
|
|
92
|
+
if (clientSecret) {
|
|
93
|
+
payload['client_secret'] = clientSecret;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Add PKCE verifier if available
|
|
97
|
+
if (this._codeVerifier) {
|
|
98
|
+
payload['code_verifier'] = this._codeVerifier;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Validate we have either client_secret or PKCE verifier
|
|
102
|
+
if (!clientSecret && !this._codeVerifier) {
|
|
103
|
+
throw new OAuthError('Either client_secret or PKCE verifier must be provided');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetch(`https://${domain}/oauth/token`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: {
|
|
110
|
+
'Content-Type': 'application/json',
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify(payload),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
const errorData = (await response.json().catch(() => ({}))) as {
|
|
117
|
+
error?: string;
|
|
118
|
+
error_description?: string;
|
|
119
|
+
};
|
|
120
|
+
this._logger.error('Token endpoint response:', errorData);
|
|
121
|
+
throw new OAuthError(
|
|
122
|
+
`Token exchange failed: ${errorData.error ?? errorData.error_description ?? response.statusText}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const tokenData = await response.json();
|
|
127
|
+
|
|
128
|
+
// Clear PKCE state after successful exchange
|
|
129
|
+
this.clearState();
|
|
130
|
+
|
|
131
|
+
return tokenData;
|
|
132
|
+
} catch (error: unknown) {
|
|
133
|
+
if (error instanceof OAuthError) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
137
|
+
throw new OAuthError(`Token exchange failed: ${errorMessage}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate state parameter to prevent CSRF attacks.
|
|
143
|
+
* @param receivedState - State received from OAuth callback
|
|
144
|
+
* @returns True if state is valid
|
|
145
|
+
*/
|
|
146
|
+
public validateState(receivedState: string): boolean {
|
|
147
|
+
return Boolean(this._currentState && receivedState === this._currentState);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Clear PKCE state (for cleanup or error handling).
|
|
152
|
+
*/
|
|
153
|
+
public clearState(): void {
|
|
154
|
+
this._codeVerifier = null;
|
|
155
|
+
this._currentState = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get current PKCE state (for debugging/testing).
|
|
160
|
+
*/
|
|
161
|
+
public getState(): PKCEState | null {
|
|
162
|
+
if (!this._codeVerifier || !this._currentState) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
codeVerifier: this._codeVerifier,
|
|
167
|
+
codeChallenge: '', // We don't store this, would need to recalculate
|
|
168
|
+
state: this._currentState,
|
|
169
|
+
timestamp: Date.now(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Generate a cryptographically secure random string.
|
|
176
|
+
* @param length - Length of the random string
|
|
177
|
+
* @returns Base64URL-encoded random string
|
|
178
|
+
*/
|
|
179
|
+
function generateRandomString(length: number): string {
|
|
180
|
+
const array = new Uint8Array(length);
|
|
181
|
+
|
|
182
|
+
if (isBrowser && window.crypto && window.crypto.getRandomValues) {
|
|
183
|
+
window.crypto.getRandomValues(array);
|
|
184
|
+
} else if (isNode) {
|
|
185
|
+
crypto.randomFillSync(array);
|
|
186
|
+
} else {
|
|
187
|
+
// Fail closed in environments without secure crypto
|
|
188
|
+
throw new Error('Secure random generator not available for PKCE.');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return base64URLEncode(array);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Cross-platform base64 encode function.
|
|
196
|
+
* @param buffer - Buffer to encode
|
|
197
|
+
* @returns Base64-encoded string
|
|
198
|
+
*/
|
|
199
|
+
function base64Encode(buffer: Uint8Array): string {
|
|
200
|
+
if (typeof globalThis !== 'undefined' && globalThis.btoa) {
|
|
201
|
+
return globalThis.btoa(String.fromCharCode(...buffer));
|
|
202
|
+
} else if (typeof Buffer !== 'undefined') {
|
|
203
|
+
return Buffer.from(buffer).toString('base64');
|
|
204
|
+
} else {
|
|
205
|
+
throw new Error('No base64 encode function available');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Base64URL encode a Uint8Array.
|
|
211
|
+
* @param buffer - Buffer to encode
|
|
212
|
+
* @returns Base64URL-encoded string
|
|
213
|
+
*/
|
|
214
|
+
function base64URLEncode(buffer: Uint8Array): string {
|
|
215
|
+
const base64 = base64Encode(buffer);
|
|
216
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Generate SHA256 hash of a string.
|
|
221
|
+
* @param str - String to hash
|
|
222
|
+
* @returns SHA256 hash
|
|
223
|
+
*/
|
|
224
|
+
async function sha256(str: string): Promise<Uint8Array> {
|
|
225
|
+
const encoder = new TextEncoder();
|
|
226
|
+
const data = encoder.encode(str);
|
|
227
|
+
|
|
228
|
+
if (isBrowser && window.crypto && window.crypto.subtle) {
|
|
229
|
+
const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
|
|
230
|
+
return new Uint8Array(hashBuffer);
|
|
231
|
+
} else if (isNode) {
|
|
232
|
+
const hash = crypto.createHash('sha256').update(str).digest();
|
|
233
|
+
return new Uint8Array(hash);
|
|
234
|
+
} else {
|
|
235
|
+
throw new Error('SHA256 not available in this environment');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Authorization URL parameters.
|
|
241
|
+
*/
|
|
242
|
+
export interface AuthorizationUrlParams {
|
|
243
|
+
/** Auth0 domain */
|
|
244
|
+
domain: string;
|
|
245
|
+
/** OAuth client ID */
|
|
246
|
+
clientId: string;
|
|
247
|
+
/** Redirect URI */
|
|
248
|
+
redirectUri: string;
|
|
249
|
+
/** API audience */
|
|
250
|
+
audience: string;
|
|
251
|
+
/** OAuth scopes */
|
|
252
|
+
scope?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Token exchange parameters.
|
|
257
|
+
*/
|
|
258
|
+
export interface TokenExchangeParams {
|
|
259
|
+
/** Auth0 domain */
|
|
260
|
+
domain: string;
|
|
261
|
+
/** OAuth client ID */
|
|
262
|
+
clientId: string;
|
|
263
|
+
/** Authorization code */
|
|
264
|
+
code: string;
|
|
265
|
+
/** Redirect URI */
|
|
266
|
+
redirectUri: string;
|
|
267
|
+
/** Client secret (for backend flows) */
|
|
268
|
+
clientSecret?: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Start a local callback server for OAuth redirect (Node.js only).
|
|
273
|
+
* @param port - Port to listen on
|
|
274
|
+
* @returns [redirectUri, waiter] tuple
|
|
275
|
+
*/
|
|
276
|
+
export function startLocalCallbackServer(port = 52765): [string, Promise<[string, string]>] {
|
|
277
|
+
if (!isNode) {
|
|
278
|
+
throw new Error('Local callback server is only available in Node.js environment');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const redirectUri = `http://localhost:${port}/cb`;
|
|
282
|
+
|
|
283
|
+
const waiter = new Promise<[string, string]>((resolve, reject) => {
|
|
284
|
+
const server = http.createServer((req, res) => {
|
|
285
|
+
const parsedUrl = url.parse(req.url ?? '', true);
|
|
286
|
+
|
|
287
|
+
if (parsedUrl.pathname === '/cb') {
|
|
288
|
+
const { code, state, error, error_description } = parsedUrl.query;
|
|
289
|
+
|
|
290
|
+
// Send response to browser
|
|
291
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
292
|
+
if (error) {
|
|
293
|
+
res.end(`
|
|
294
|
+
<html>
|
|
295
|
+
<body>
|
|
296
|
+
<h1>Authentication Failed</h1>
|
|
297
|
+
<p>Error: Authentication error occurred.</p>
|
|
298
|
+
<p>Description: Please return to the application for details.</p>
|
|
299
|
+
<p>You can close this window.</p>
|
|
300
|
+
</body>
|
|
301
|
+
</html>
|
|
302
|
+
`);
|
|
303
|
+
server.close();
|
|
304
|
+
reject(new OAuthError(`OAuth error: ${error} - ${error_description}`));
|
|
305
|
+
} else if (code) {
|
|
306
|
+
res.end(`
|
|
307
|
+
<html>
|
|
308
|
+
<body>
|
|
309
|
+
<h1>Authentication Successful</h1>
|
|
310
|
+
<p>You can close this window and return to your application.</p>
|
|
311
|
+
</body>
|
|
312
|
+
</html>
|
|
313
|
+
`);
|
|
314
|
+
server.close();
|
|
315
|
+
resolve([code as string, state as string]);
|
|
316
|
+
} else {
|
|
317
|
+
res.end(`
|
|
318
|
+
<html>
|
|
319
|
+
<body>
|
|
320
|
+
<h1>Authentication Failed</h1>
|
|
321
|
+
<p>No authorization code received.</p>
|
|
322
|
+
<p>You can close this window.</p>
|
|
323
|
+
</body>
|
|
324
|
+
</html>
|
|
325
|
+
`);
|
|
326
|
+
server.close();
|
|
327
|
+
reject(new OAuthError('No authorization code received'));
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
res.writeHead(404);
|
|
331
|
+
res.end('Not found');
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
server.listen(port, 'localhost', () => {
|
|
336
|
+
// eslint-disable-next-line no-console
|
|
337
|
+
console.log(`OAuth callback server listening on ${redirectUri}`);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
server.on('error', (error: Error) => {
|
|
341
|
+
reject(new OAuthError(`Failed to start callback server: ${error.message}`));
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
return [redirectUri, waiter];
|
|
346
|
+
}
|