carto-cli 0.1.0-rc.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.
@@ -0,0 +1,485 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AUTH_CONFIGS = exports.M2M_AUTH_CONFIGS = exports.DEFAULT_AUTH_ENVIRONMENT = void 0;
4
+ exports.parseEnvironment = parseEnvironment;
5
+ exports.getAccountsUrl = getAccountsUrl;
6
+ exports.getAuthConfig = getAuthConfig;
7
+ exports.generateCodeVerifier = generateCodeVerifier;
8
+ exports.generateCodeChallenge = generateCodeChallenge;
9
+ exports.generatePKCEChallenge = generatePKCEChallenge;
10
+ exports.generateState = generateState;
11
+ exports.buildAuthorizationUrl = buildAuthorizationUrl;
12
+ exports.getAuth0OrganizationIdByName = getAuth0OrganizationIdByName;
13
+ exports.exchangeCodeForToken = exchangeCodeForToken;
14
+ exports.decodeJWT = decodeJWT;
15
+ exports.getTokenExpiration = getTokenExpiration;
16
+ exports.getTokenIssuedAt = getTokenIssuedAt;
17
+ exports.isTokenExpired = isTokenExpired;
18
+ exports.getTokenLifetime = getTokenLifetime;
19
+ exports.getTokenTimeRemaining = getTokenTimeRemaining;
20
+ exports.shouldWarnExpiration = shouldWarnExpiration;
21
+ exports.formatTimeRemaining = formatTimeRemaining;
22
+ exports.getM2MAuthConfig = getM2MAuthConfig;
23
+ exports.exchangeM2MCredentialsForToken = exchangeM2MCredentialsForToken;
24
+ const crypto_1 = require("crypto");
25
+ const https_1 = require("https");
26
+ // Default authentication environment when --env flag is not provided
27
+ exports.DEFAULT_AUTH_ENVIRONMENT = 'production';
28
+ exports.M2M_AUTH_CONFIGS = {
29
+ 'production': {
30
+ domain: 'auth.carto.com',
31
+ audience: 'carto-cloud-native-api'
32
+ },
33
+ 'staging': {
34
+ domain: 'auth.stag.carto.com',
35
+ audience: 'carto-cloud-native-api'
36
+ },
37
+ 'local': {
38
+ domain: 'auth.local.carto.com',
39
+ audience: 'carto-cloud-native-api'
40
+ },
41
+ 'dedicated': {
42
+ domain: 'auth.dev.carto.com',
43
+ audience: 'carto-cloud-native-api'
44
+ }
45
+ };
46
+ // Environment configurations
47
+ // Note: dedicated-XX environments share the same auth domain but have different accounts URLs
48
+ exports.AUTH_CONFIGS = {
49
+ 'local': {
50
+ domain: 'auth.local.carto.com',
51
+ clientId: '6EPxnXidfZZicawFOZ4Vw35bI5ONSmnP',
52
+ audience: 'carto-cloud-native-api'
53
+ },
54
+ 'dedicated': {
55
+ domain: 'auth.dev.carto.com',
56
+ clientId: 'mBhNaKPjgpiCr92t1ljs5NVn2ODrev7p',
57
+ audience: 'carto-cloud-native-api'
58
+ },
59
+ 'staging': {
60
+ domain: 'auth.stag.carto.com',
61
+ clientId: 'SezYkOlDCEiX46j6ljkKW2BbyNqKq82G',
62
+ audience: 'carto-cloud-native-api'
63
+ },
64
+ 'production': {
65
+ domain: 'auth.carto.com',
66
+ clientId: '6eeVNUr9Ss2u3h972UPShWFRbKbkZHSR',
67
+ audience: 'carto-cloud-native-api'
68
+ }
69
+ };
70
+ /**
71
+ * Parse environment string to extract type and dedicated ID
72
+ * Examples: "dedicated-01" -> {type: "dedicated", dedicatedId: "01"}
73
+ * "production" -> {type: "production"}
74
+ */
75
+ function parseEnvironment(env) {
76
+ // Match dedicated-NN pattern
77
+ const dedicatedMatch = env.match(/^dedicated-(\d+)$/);
78
+ if (dedicatedMatch) {
79
+ return {
80
+ type: 'dedicated',
81
+ dedicatedId: dedicatedMatch[1]
82
+ };
83
+ }
84
+ // Check if it's a known base environment
85
+ if (env === 'local' || env === 'staging' || env === 'production') {
86
+ return { type: env };
87
+ }
88
+ // If user provides bare "dedicated", throw error
89
+ if (env === 'dedicated') {
90
+ throw new Error('Environment "dedicated" requires a specific ID. Use format: dedicated-01, dedicated-02, etc.');
91
+ }
92
+ throw new Error(`Unknown environment: ${env}. Valid formats: local, staging, production, dedicated-NN`);
93
+ }
94
+ /**
95
+ * Get accounts API URL for the specified environment
96
+ *
97
+ * @param env Environment string (e.g., "dedicated-26", "production")
98
+ * @returns Accounts API base URL
99
+ */
100
+ function getAccountsUrl(env) {
101
+ const parsed = parseEnvironment(env);
102
+ switch (parsed.type) {
103
+ case 'dedicated':
104
+ // Pattern: https://accounts-{N}.dev.app.carto.com
105
+ return `https://accounts-${parsed.dedicatedId}.dev.app.carto.com`;
106
+ case 'local':
107
+ return 'http://localhost:8000';
108
+ case 'staging':
109
+ return 'https://accounts.stag.app.carto.com';
110
+ case 'production':
111
+ return 'https://accounts.app.carto.com';
112
+ default:
113
+ throw new Error(`Unable to determine accounts URL for environment: ${env}`);
114
+ }
115
+ }
116
+ /**
117
+ * Get OAuth config for authentication
118
+ *
119
+ * Environment selection priority:
120
+ * 1. Explicit environment override (e.g., dedicated-01, production)
121
+ * 2. CARTO_AUTH_ENV environment variable
122
+ * 3. Default: production
123
+ *
124
+ * @param envOverride Optional environment override
125
+ */
126
+ function getAuthConfig(envOverride) {
127
+ const env = envOverride || process.env.CARTO_AUTH_ENV || exports.DEFAULT_AUTH_ENVIRONMENT;
128
+ // Parse environment to get the base type
129
+ const parsed = parseEnvironment(env);
130
+ // Return config for the base type
131
+ const config = exports.AUTH_CONFIGS[parsed.type];
132
+ if (!config) {
133
+ throw new Error(`No authentication config found for environment type: ${parsed.type}`);
134
+ }
135
+ return config;
136
+ }
137
+ /**
138
+ * Generate PKCE code verifier (random string)
139
+ * RFC 7636: 43-128 characters, unreserved characters [A-Z] [a-z] [0-9] - . _ ~
140
+ */
141
+ function generateCodeVerifier() {
142
+ const buffer = (0, crypto_1.randomBytes)(32);
143
+ return base64URLEncode(buffer);
144
+ }
145
+ /**
146
+ * Generate PKCE code challenge from verifier
147
+ * RFC 7636: BASE64URL(SHA256(ASCII(code_verifier)))
148
+ */
149
+ function generateCodeChallenge(verifier) {
150
+ const hash = (0, crypto_1.createHash)('sha256').update(verifier).digest();
151
+ return base64URLEncode(hash);
152
+ }
153
+ /**
154
+ * Base64 URL-safe encoding (no padding)
155
+ */
156
+ function base64URLEncode(buffer) {
157
+ return buffer.toString('base64')
158
+ .replace(/\+/g, '-')
159
+ .replace(/\//g, '_')
160
+ .replace(/=/g, '');
161
+ }
162
+ /**
163
+ * Generate PKCE challenge pair
164
+ */
165
+ function generatePKCEChallenge() {
166
+ const verifier = generateCodeVerifier();
167
+ const challenge = generateCodeChallenge(verifier);
168
+ return { verifier, challenge };
169
+ }
170
+ /**
171
+ * Generate random state parameter for CSRF protection
172
+ */
173
+ function generateState() {
174
+ return base64URLEncode((0, crypto_1.randomBytes)(32));
175
+ }
176
+ /**
177
+ * Build OAuth authorization URL
178
+ */
179
+ function buildAuthorizationUrl(config, redirectUri, codeChallenge, state, auth0OrganizationId) {
180
+ const params = new URLSearchParams({
181
+ response_type: 'code',
182
+ client_id: config.clientId,
183
+ redirect_uri: redirectUri,
184
+ code_challenge: codeChallenge,
185
+ code_challenge_method: 'S256',
186
+ state: state,
187
+ audience: config.audience,
188
+ scope: 'openid profile email read:current_user update:current_user read:connections write:connections read:maps write:maps read:account admin:account',
189
+ prompt: 'login' // Force credential entry every time
190
+ });
191
+ // Add Auth0 organization parameter if provided (for SSO login)
192
+ // Note: this is Auth0's organization ID, not CARTO's organization ID (ac_xxxxx)
193
+ if (auth0OrganizationId) {
194
+ params.append('organization', auth0OrganizationId);
195
+ }
196
+ return `https://${config.domain}/authorize?${params.toString()}`;
197
+ }
198
+ /**
199
+ * Get Auth0 organization ID by CARTO organization name
200
+ * Returns null if organization doesn't have SSO configured
201
+ */
202
+ async function getAuth0OrganizationIdByName(accountsUrl, organizationName) {
203
+ return new Promise((resolve, reject) => {
204
+ const url = new URL(`${accountsUrl}/accounts/${encodeURIComponent(organizationName)}/auth0_org_id`);
205
+ const request = (0, https_1.request)({
206
+ hostname: url.hostname,
207
+ path: url.pathname,
208
+ headers: { 'Accept': 'application/json' }
209
+ }, (res) => {
210
+ let data = '';
211
+ res.on('data', (chunk) => { data += chunk; });
212
+ res.on('end', () => {
213
+ if (res.statusCode !== 200) {
214
+ reject(new Error(`Organization not found: ${organizationName}`));
215
+ return;
216
+ }
217
+ try {
218
+ const parsed = JSON.parse(data);
219
+ resolve(parsed.auth0orgId || null);
220
+ }
221
+ catch (err) {
222
+ reject(new Error(`Failed to parse response: ${err.message}`));
223
+ }
224
+ });
225
+ });
226
+ request.on('error', (err) => {
227
+ reject(new Error(`Failed to fetch organization: ${err.message}`));
228
+ });
229
+ request.end();
230
+ });
231
+ }
232
+ /**
233
+ * Exchange authorization code for access token
234
+ */
235
+ async function exchangeCodeForToken(config, code, codeVerifier, redirectUri) {
236
+ const tokenEndpoint = `https://${config.domain}/oauth/token`;
237
+ const body = new URLSearchParams({
238
+ grant_type: 'authorization_code',
239
+ client_id: config.clientId,
240
+ code: code,
241
+ code_verifier: codeVerifier,
242
+ redirect_uri: redirectUri
243
+ }).toString();
244
+ return new Promise((resolve, reject) => {
245
+ const url = new URL(tokenEndpoint);
246
+ const options = {
247
+ hostname: url.hostname,
248
+ port: url.port,
249
+ path: url.pathname,
250
+ method: 'POST',
251
+ headers: {
252
+ 'Content-Type': 'application/x-www-form-urlencoded',
253
+ 'Content-Length': Buffer.byteLength(body)
254
+ }
255
+ };
256
+ const req = (0, https_1.request)(options, (res) => {
257
+ let data = '';
258
+ res.on('data', (chunk) => {
259
+ data += chunk;
260
+ });
261
+ res.on('end', () => {
262
+ try {
263
+ const result = JSON.parse(data);
264
+ if (res.statusCode === 200) {
265
+ resolve(result);
266
+ }
267
+ else {
268
+ reject(new Error(result.error_description || result.error || 'Token exchange failed'));
269
+ }
270
+ }
271
+ catch (err) {
272
+ reject(new Error('Failed to parse token response: ' + data));
273
+ }
274
+ });
275
+ });
276
+ req.on('error', (err) => {
277
+ reject(err);
278
+ });
279
+ req.write(body);
280
+ req.end();
281
+ });
282
+ }
283
+ /**
284
+ * Extract tenant info from ID token (JWT)
285
+ * Note: This is a simple JWT decode without verification
286
+ * The token is already validated by Auth0
287
+ */
288
+ function decodeJWT(token) {
289
+ try {
290
+ const parts = token.split('.');
291
+ if (parts.length !== 3) {
292
+ throw new Error('Invalid JWT format');
293
+ }
294
+ const payload = parts[1];
295
+ const decoded = Buffer.from(payload, 'base64').toString('utf-8');
296
+ return JSON.parse(decoded);
297
+ }
298
+ catch (err) {
299
+ throw new Error('Failed to decode JWT: ' + err.message);
300
+ }
301
+ }
302
+ /**
303
+ * Get token expiration timestamp from JWT
304
+ * @param token JWT token string
305
+ * @returns Unix timestamp (seconds) or null if not found/invalid
306
+ */
307
+ function getTokenExpiration(token) {
308
+ try {
309
+ const payload = decodeJWT(token);
310
+ return payload.exp || null;
311
+ }
312
+ catch (err) {
313
+ return null;
314
+ }
315
+ }
316
+ /**
317
+ * Get token issued at timestamp from JWT
318
+ * @param token JWT token string
319
+ * @returns Unix timestamp (seconds) or null if not found/invalid
320
+ */
321
+ function getTokenIssuedAt(token) {
322
+ try {
323
+ const payload = decodeJWT(token);
324
+ return payload.iat || null;
325
+ }
326
+ catch (err) {
327
+ return null;
328
+ }
329
+ }
330
+ /**
331
+ * Check if token is expired
332
+ * @param token JWT token string
333
+ * @returns true if expired, false if valid or cannot determine
334
+ */
335
+ function isTokenExpired(token) {
336
+ const exp = getTokenExpiration(token);
337
+ if (!exp)
338
+ return false; // Cannot determine, assume valid
339
+ const now = Math.floor(Date.now() / 1000);
340
+ return exp < now;
341
+ }
342
+ /**
343
+ * Get token lifetime in seconds (from issued at to expiration)
344
+ * @param token JWT token string
345
+ * @returns Lifetime in seconds or null if cannot determine
346
+ */
347
+ function getTokenLifetime(token) {
348
+ const exp = getTokenExpiration(token);
349
+ const iat = getTokenIssuedAt(token);
350
+ if (!exp || !iat)
351
+ return null;
352
+ return exp - iat;
353
+ }
354
+ /**
355
+ * Get time remaining until token expiration
356
+ * @param token JWT token string
357
+ * @returns Seconds remaining (negative if expired), or null if cannot determine
358
+ */
359
+ function getTokenTimeRemaining(token) {
360
+ const exp = getTokenExpiration(token);
361
+ if (!exp)
362
+ return null;
363
+ const now = Math.floor(Date.now() / 1000);
364
+ return exp - now;
365
+ }
366
+ /**
367
+ * Check if token should show expiration warning (< 10% of lifetime remaining)
368
+ * @param token JWT token string
369
+ * @returns true if should warn, false otherwise
370
+ */
371
+ function shouldWarnExpiration(token) {
372
+ const lifetime = getTokenLifetime(token);
373
+ const remaining = getTokenTimeRemaining(token);
374
+ if (!lifetime || !remaining)
375
+ return false;
376
+ if (remaining <= 0)
377
+ return false; // Already expired, not just warning
378
+ const threshold = lifetime * 0.1; // 10% of lifetime
379
+ return remaining < threshold;
380
+ }
381
+ /**
382
+ * Format seconds into human-readable time string
383
+ * @param seconds Time in seconds (can be negative for expired)
384
+ * @returns Formatted string like "2 hours", "30 minutes", "EXPIRED 3 hours ago"
385
+ */
386
+ function formatTimeRemaining(seconds) {
387
+ const absSeconds = Math.abs(seconds);
388
+ const isExpired = seconds < 0;
389
+ let timeStr;
390
+ if (absSeconds < 60) {
391
+ timeStr = `${absSeconds} second${absSeconds !== 1 ? 's' : ''}`;
392
+ }
393
+ else if (absSeconds < 3600) {
394
+ const mins = Math.floor(absSeconds / 60);
395
+ timeStr = `${mins} minute${mins !== 1 ? 's' : ''}`;
396
+ }
397
+ else if (absSeconds < 86400) {
398
+ const hours = Math.floor(absSeconds / 3600);
399
+ timeStr = `${hours} hour${hours !== 1 ? 's' : ''}`;
400
+ }
401
+ else {
402
+ const days = Math.floor(absSeconds / 86400);
403
+ timeStr = `${days} day${days !== 1 ? 's' : ''}`;
404
+ }
405
+ return isExpired ? `EXPIRED ${timeStr} ago` : timeStr;
406
+ }
407
+ /**
408
+ * Get M2M authentication config for the specified environment
409
+ *
410
+ * @param envOverride Optional environment override
411
+ */
412
+ function getM2MAuthConfig(envOverride) {
413
+ const env = envOverride || process.env.CARTO_AUTH_ENV || exports.DEFAULT_AUTH_ENVIRONMENT;
414
+ const parsed = parseEnvironment(env);
415
+ const config = exports.M2M_AUTH_CONFIGS[parsed.type];
416
+ if (!config) {
417
+ throw new Error(`M2M authentication config not found for environment: ${parsed.type}`);
418
+ }
419
+ return config;
420
+ }
421
+ /**
422
+ * Exchange M2M client credentials for access token
423
+ * Uses OAuth2 client_credentials grant
424
+ *
425
+ * @param config M2M authentication configuration
426
+ * @param clientId M2M OAuth client ID
427
+ * @param clientSecret M2M OAuth client secret
428
+ * @returns Token response with access_token, expires_in, token_type
429
+ */
430
+ async function exchangeM2MCredentialsForToken(config, clientId, clientSecret) {
431
+ const tokenEndpoint = `https://${config.domain}/oauth/token`;
432
+ // Build form-urlencoded body per CARTO docs
433
+ const body = new URLSearchParams({
434
+ grant_type: 'client_credentials',
435
+ client_id: clientId,
436
+ client_secret: clientSecret,
437
+ audience: config.audience
438
+ }).toString();
439
+ return new Promise((resolve, reject) => {
440
+ const url = new URL(tokenEndpoint);
441
+ const options = {
442
+ hostname: url.hostname,
443
+ port: url.port,
444
+ path: url.pathname,
445
+ method: 'POST',
446
+ headers: {
447
+ 'Content-Type': 'application/x-www-form-urlencoded',
448
+ 'Content-Length': Buffer.byteLength(body)
449
+ }
450
+ };
451
+ const req = (0, https_1.request)(options, (res) => {
452
+ let data = '';
453
+ res.on('data', (chunk) => {
454
+ data += chunk;
455
+ });
456
+ res.on('end', () => {
457
+ try {
458
+ const result = JSON.parse(data);
459
+ if (res.statusCode === 200) {
460
+ resolve(result);
461
+ }
462
+ else {
463
+ // Provide user-friendly error messages
464
+ let errorMessage = result.error_description || result.error || 'M2M authentication failed';
465
+ if (errorMessage.includes('Unauthorized')) {
466
+ errorMessage = 'Invalid M2M credentials. Please check your client_id and client_secret.';
467
+ }
468
+ else if (errorMessage.includes('access_denied')) {
469
+ errorMessage = 'Access denied. Ensure your M2M OAuth client is properly configured.';
470
+ }
471
+ reject(new Error(errorMessage));
472
+ }
473
+ }
474
+ catch (err) {
475
+ reject(new Error('Failed to parse M2M authentication response'));
476
+ }
477
+ });
478
+ });
479
+ req.on('error', (err) => {
480
+ reject(new Error(`M2M authentication request failed: ${err.message}`));
481
+ });
482
+ req.write(body);
483
+ req.end();
484
+ });
485
+ }