@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.
@@ -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
+ }