newo 1.5.0 → 1.5.2

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/src/auth.ts CHANGED
@@ -1,36 +1,179 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
- import axios from 'axios';
4
- import dotenv from 'dotenv';
5
- import type { NewoEnvironment, TokenResponse, StoredTokens } from './types.js';
3
+ import axios, { AxiosError } from 'axios';
4
+ import { ENV } from './env.js';
5
+ import { customerStateDir } from './fsutil.js';
6
+ import type { TokenResponse, StoredTokens, CustomerConfig } from './types.js';
6
7
 
7
- dotenv.config();
8
+ const STATE_DIR = path.join(process.cwd(), '.newo');
8
9
 
9
- const {
10
- NEWO_BASE_URL,
11
- NEWO_API_KEY,
12
- NEWO_ACCESS_TOKEN,
13
- NEWO_REFRESH_TOKEN,
14
- NEWO_REFRESH_URL
15
- } = process.env as NewoEnvironment;
10
+ // Constants for validation and timeouts
11
+ const API_KEY_MIN_LENGTH = 10;
12
+ const TOKEN_MIN_LENGTH = 20;
13
+ const REQUEST_TIMEOUT = 30000; // 30 seconds
14
+ const TOKEN_EXPIRY_BUFFER = 60000; // 1 minute buffer for token expiry
16
15
 
17
- const STATE_DIR = path.join(process.cwd(), '.newo');
18
- const TOKENS_PATH = path.join(STATE_DIR, 'tokens.json');
16
+ // Validation functions
17
+ function validateApiKey(apiKey: string, customerIdn?: string): void {
18
+ if (!apiKey || typeof apiKey !== 'string') {
19
+ throw new Error(`Invalid API key format${customerIdn ? ` for customer ${customerIdn}` : ''}: must be a non-empty string`);
20
+ }
21
+ if (apiKey.length < API_KEY_MIN_LENGTH) {
22
+ throw new Error(`API key too short${customerIdn ? ` for customer ${customerIdn}` : ''}: minimum ${API_KEY_MIN_LENGTH} characters required`);
23
+ }
24
+ if (apiKey.includes(' ') || apiKey.includes('\n') || apiKey.includes('\t')) {
25
+ throw new Error(`Invalid API key format${customerIdn ? ` for customer ${customerIdn}` : ''}: contains invalid characters`);
26
+ }
27
+ }
28
+
29
+ function validateTokens(tokens: StoredTokens): void {
30
+ if (!tokens.access_token || typeof tokens.access_token !== 'string' || tokens.access_token.length < TOKEN_MIN_LENGTH) {
31
+ throw new Error('Invalid access token format: must be a non-empty string with minimum length');
32
+ }
33
+ if (tokens.refresh_token && (typeof tokens.refresh_token !== 'string' || tokens.refresh_token.length < TOKEN_MIN_LENGTH)) {
34
+ throw new Error('Invalid refresh token format: must be a non-empty string with minimum length');
35
+ }
36
+ if (tokens.expires_at && (typeof tokens.expires_at !== 'number' || tokens.expires_at <= 0)) {
37
+ throw new Error('Invalid token expiry: must be a positive number');
38
+ }
39
+ }
40
+
41
+ function validateUrl(url: string, name: string): void {
42
+ if (!url || typeof url !== 'string') {
43
+ throw new Error(`${name} must be a non-empty string`);
44
+ }
45
+ try {
46
+ new URL(url);
47
+ } catch {
48
+ throw new Error(`${name} must be a valid URL format`);
49
+ }
50
+ if (!url.startsWith('https://') && !url.startsWith('http://')) {
51
+ throw new Error(`${name} must use HTTP or HTTPS protocol`);
52
+ }
53
+ }
54
+
55
+ // Enhanced logging function
56
+ function logAuthEvent(level: 'info' | 'warn' | 'error', message: string, meta?: Record<string, unknown>): void {
57
+ const timestamp = new Date().toISOString();
58
+ const logEntry = {
59
+ timestamp,
60
+ level,
61
+ module: 'auth',
62
+ message,
63
+ ...meta
64
+ };
65
+
66
+ // Sanitize sensitive data
67
+ const sanitized = JSON.parse(JSON.stringify(logEntry, (key, value) => {
68
+ if (typeof key === 'string' && (key.toLowerCase().includes('key') || key.toLowerCase().includes('token') || key.toLowerCase().includes('secret'))) {
69
+ return typeof value === 'string' ? `${value.slice(0, 8)}...` : value;
70
+ }
71
+ return value;
72
+ }));
73
+
74
+ if (level === 'error') {
75
+ console.error(JSON.stringify(sanitized));
76
+ } else if (level === 'warn') {
77
+ console.warn(JSON.stringify(sanitized));
78
+ } else {
79
+ console.log(JSON.stringify(sanitized));
80
+ }
81
+ }
82
+
83
+ // Enhanced error handling for network requests
84
+ function handleNetworkError(error: unknown, operation: string, customerIdn?: string): never {
85
+ const customerInfo = customerIdn ? ` for customer ${customerIdn}` : '';
86
+
87
+ if (error instanceof AxiosError) {
88
+ const statusCode = error.response?.status;
89
+ const responseData = error.response?.data;
90
+
91
+ if (statusCode === 401) {
92
+ throw new Error(`Authentication failed${customerInfo}: Invalid API key or credentials`);
93
+ } else if (statusCode === 403) {
94
+ throw new Error(`Access forbidden${customerInfo}: Insufficient permissions`);
95
+ } else if (statusCode === 429) {
96
+ throw new Error(`Rate limit exceeded${customerInfo}: Please try again later`);
97
+ } else if (statusCode && statusCode >= 500) {
98
+ throw new Error(`Server error${customerInfo}: The NEWO service is temporarily unavailable (${statusCode})`);
99
+ } else if (error.code === 'ECONNREFUSED') {
100
+ throw new Error(`Connection refused${customerInfo}: Cannot reach NEWO service`);
101
+ } else if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
102
+ throw new Error(`Network timeout${customerInfo}: Check your internet connection`);
103
+ } else {
104
+ throw new Error(`Network error during ${operation}${customerInfo}: ${error.message}${responseData ? ` - ${JSON.stringify(responseData)}` : ''}`);
105
+ }
106
+ }
107
+
108
+ throw new Error(`Failed to ${operation}${customerInfo}: ${error instanceof Error ? error.message : String(error)}`);
109
+ }
19
110
 
20
- async function saveTokens(tokens: StoredTokens): Promise<void> {
21
- await fs.ensureDir(STATE_DIR);
22
- await fs.writeJson(TOKENS_PATH, tokens, { spaces: 2 });
111
+ function tokensPath(customerIdn?: string): string {
112
+ if (customerIdn) {
113
+ return path.join(customerStateDir(customerIdn), 'tokens.json');
114
+ }
115
+ return path.join(STATE_DIR, 'tokens.json'); // Legacy path
23
116
  }
24
117
 
25
- async function loadTokens(): Promise<StoredTokens | null> {
26
- if (await fs.pathExists(TOKENS_PATH)) {
27
- return fs.readJson(TOKENS_PATH) as Promise<StoredTokens>;
118
+ async function saveTokens(tokens: StoredTokens, customerIdn?: string): Promise<void> {
119
+ try {
120
+ validateTokens(tokens);
121
+
122
+ const filePath = tokensPath(customerIdn);
123
+ await fs.ensureDir(path.dirname(filePath));
124
+ await fs.writeJson(filePath, tokens, { spaces: 2 });
125
+
126
+ logAuthEvent('info', 'Tokens saved successfully', {
127
+ customerIdn: customerIdn || 'legacy',
128
+ expiresAt: tokens.expires_at ? new Date(tokens.expires_at).toISOString() : undefined,
129
+ hasRefreshToken: !!tokens.refresh_token
130
+ });
131
+ } catch (error: unknown) {
132
+ logAuthEvent('error', 'Failed to save tokens', {
133
+ customerIdn: customerIdn || 'legacy',
134
+ error: error instanceof Error ? error.message : String(error)
135
+ });
136
+ throw new Error(`Failed to save authentication tokens${customerIdn ? ` for customer ${customerIdn}` : ''}: ${error instanceof Error ? error.message : String(error)}`);
137
+ }
138
+ }
139
+
140
+ async function loadTokens(customerIdn?: string): Promise<StoredTokens | null> {
141
+ try {
142
+ const filePath = tokensPath(customerIdn);
143
+ if (await fs.pathExists(filePath)) {
144
+ const tokens = await fs.readJson(filePath) as StoredTokens;
145
+
146
+ // Validate loaded tokens
147
+ try {
148
+ validateTokens(tokens);
149
+ } catch (validationError: unknown) {
150
+ logAuthEvent('warn', 'Loaded tokens failed validation, will regenerate', {
151
+ customerIdn: customerIdn || 'legacy',
152
+ error: validationError instanceof Error ? validationError.message : String(validationError)
153
+ });
154
+ return null; // Force token regeneration
155
+ }
156
+
157
+ logAuthEvent('info', 'Tokens loaded successfully', {
158
+ customerIdn: customerIdn || 'legacy',
159
+ expiresAt: tokens.expires_at ? new Date(tokens.expires_at).toISOString() : undefined,
160
+ hasRefreshToken: !!tokens.refresh_token
161
+ });
162
+
163
+ return tokens;
164
+ }
165
+ } catch (error: unknown) {
166
+ logAuthEvent('warn', 'Failed to load tokens from file', {
167
+ customerIdn: customerIdn || 'legacy',
168
+ error: error instanceof Error ? error.message : String(error)
169
+ });
28
170
  }
29
171
 
30
- if (NEWO_ACCESS_TOKEN || NEWO_REFRESH_TOKEN) {
172
+ // Fallback to environment tokens for legacy mode or bootstrap
173
+ if (!customerIdn && (ENV.NEWO_ACCESS_TOKEN || ENV.NEWO_REFRESH_TOKEN)) {
31
174
  const tokens: StoredTokens = {
32
- access_token: NEWO_ACCESS_TOKEN || '',
33
- refresh_token: NEWO_REFRESH_TOKEN || '',
175
+ access_token: ENV.NEWO_ACCESS_TOKEN || '',
176
+ refresh_token: ENV.NEWO_REFRESH_TOKEN || '',
34
177
  expires_at: Date.now() + 10 * 60 * 1000
35
178
  };
36
179
  await saveTokens(tokens);
@@ -41,102 +184,232 @@ async function loadTokens(): Promise<StoredTokens | null> {
41
184
  }
42
185
 
43
186
  function isExpired(tokens: StoredTokens | null): boolean {
44
- if (!tokens?.expires_at) return false;
45
- return Date.now() >= tokens.expires_at - 10_000;
46
- }
47
-
48
- export async function exchangeApiKeyForToken(): Promise<StoredTokens> {
49
- if (!NEWO_API_KEY) {
50
- throw new Error('NEWO_API_KEY not set. Provide an API key in .env');
187
+ if (!tokens?.expires_at) {
188
+ logAuthEvent('warn', 'Token has no expiry time, treating as expired');
189
+ return true;
51
190
  }
52
191
 
53
- const url = `${NEWO_BASE_URL}/api/v1/auth/api-key/token`;
54
- const response = await axios.post<TokenResponse>(
55
- url,
56
- {},
57
- {
58
- headers: {
59
- 'x-api-key': NEWO_API_KEY,
60
- 'accept': 'application/json'
61
- }
62
- }
63
- );
192
+ const currentTime = Date.now();
193
+ const expiryTime = tokens.expires_at;
194
+ const timeUntilExpiry = expiryTime - currentTime;
195
+
196
+ if (timeUntilExpiry <= TOKEN_EXPIRY_BUFFER) {
197
+ logAuthEvent('info', 'Token is expired or expires soon', {
198
+ expiresAt: new Date(expiryTime).toISOString(),
199
+ timeUntilExpiry: Math.round(timeUntilExpiry / 1000)
200
+ });
201
+ return true;
202
+ }
64
203
 
65
- const data = response.data;
66
- const access = data.access_token || data.token || data.accessToken;
67
- const refresh = data.refresh_token || data.refreshToken || '';
68
- const expiresInSec = data.expires_in || data.expiresIn || 3600;
204
+ return false;
205
+ }
206
+
207
+ function normalizeTokenResponse(tokenResponse: TokenResponse): { access: string; refresh: string; expiresInSec: number } {
208
+ const access = tokenResponse.access_token || tokenResponse.token || tokenResponse.accessToken;
209
+ const refresh = tokenResponse.refresh_token || tokenResponse.refreshToken || '';
210
+ const expiresInSec = tokenResponse.expires_in || tokenResponse.expiresIn || 3600;
69
211
 
70
212
  if (!access) {
71
- throw new Error('Failed to get access token from API key exchange');
213
+ throw new Error('Invalid token response: missing access token');
72
214
  }
73
215
 
74
- const tokens: StoredTokens = {
75
- access_token: access,
76
- refresh_token: refresh,
77
- expires_at: Date.now() + expiresInSec * 1000
78
- };
79
-
80
- await saveTokens(tokens);
81
- return tokens;
216
+ return { access, refresh, expiresInSec };
82
217
  }
83
218
 
84
- export async function refreshWithEndpoint(refreshToken: string): Promise<StoredTokens> {
85
- if (!NEWO_REFRESH_URL) {
86
- throw new Error('NEWO_REFRESH_URL not set');
219
+ export async function exchangeApiKeyForToken(customer?: CustomerConfig): Promise<StoredTokens> {
220
+ const apiKey = customer?.apiKey || ENV.NEWO_API_KEY;
221
+ const customerIdn = customer?.idn;
222
+
223
+ // Validate inputs
224
+ if (!apiKey) {
225
+ throw new Error(customer
226
+ ? `API key not set for customer ${customer.idn}. Set NEWO_CUSTOMER_${customer.idn.toUpperCase()}_API_KEY in your environment`
227
+ : 'NEWO_API_KEY not set. Provide an API key in .env file'
228
+ );
87
229
  }
88
230
 
89
- const response = await axios.post<TokenResponse>(
90
- NEWO_REFRESH_URL,
91
- { refresh_token: refreshToken },
92
- { headers: { 'accept': 'application/json' } }
93
- );
231
+ validateApiKey(apiKey, customerIdn);
232
+ validateUrl(ENV.NEWO_BASE_URL, 'NEWO_BASE_URL');
94
233
 
95
- const data = response.data;
96
- const access = data.access_token || data.token || data.accessToken;
97
- const refresh = data.refresh_token ?? refreshToken;
98
- const expiresInSec = data.expires_in || 3600;
234
+ logAuthEvent('info', 'Exchanging API key for tokens', { customerIdn: customerIdn || 'legacy' });
99
235
 
100
- if (!access) {
101
- throw new Error('Failed to get access token from refresh');
236
+ try {
237
+ const url = `${ENV.NEWO_BASE_URL}/api/v1/auth/api-key/token`;
238
+ const response = await axios.post<TokenResponse>(
239
+ url,
240
+ {},
241
+ {
242
+ timeout: REQUEST_TIMEOUT,
243
+ headers: {
244
+ 'x-api-key': apiKey,
245
+ 'accept': 'application/json',
246
+ 'user-agent': 'newo-cli/1.5.0'
247
+ }
248
+ }
249
+ );
250
+
251
+ if (!response.data) {
252
+ throw new Error('Empty response from token exchange endpoint');
253
+ }
254
+
255
+ const { access, refresh, expiresInSec } = normalizeTokenResponse(response.data);
256
+
257
+ const tokens: StoredTokens = {
258
+ access_token: access,
259
+ refresh_token: refresh,
260
+ expires_at: Date.now() + expiresInSec * 1000
261
+ };
262
+
263
+ // Validate tokens before saving
264
+ validateTokens(tokens);
265
+
266
+ await saveTokens(tokens, customerIdn);
267
+
268
+ logAuthEvent('info', 'API key exchange completed successfully', {
269
+ customerIdn: customerIdn || 'legacy',
270
+ expiresAt: new Date(tokens.expires_at).toISOString()
271
+ });
272
+
273
+ return tokens;
274
+ } catch (error: unknown) {
275
+ logAuthEvent('error', 'API key exchange failed', {
276
+ customerIdn: customerIdn || 'legacy',
277
+ error: error instanceof Error ? error.message : String(error)
278
+ });
279
+ handleNetworkError(error, 'exchange API key for token', customerIdn);
102
280
  }
103
-
104
- const tokens: StoredTokens = {
105
- access_token: access,
106
- refresh_token: refresh,
107
- expires_at: Date.now() + expiresInSec * 1000
108
- };
109
-
110
- await saveTokens(tokens);
111
- return tokens;
112
281
  }
113
282
 
114
- export async function getValidAccessToken(): Promise<string> {
115
- let tokens = await loadTokens();
283
+ export async function refreshWithEndpoint(refreshToken: string, customer?: CustomerConfig): Promise<StoredTokens> {
284
+ const customerIdn = customer?.idn;
116
285
 
117
- if (!tokens || !tokens.access_token) {
118
- tokens = await exchangeApiKeyForToken();
119
- return tokens.access_token;
286
+ // Validate inputs
287
+ if (!ENV.NEWO_REFRESH_URL) {
288
+ throw new Error('NEWO_REFRESH_URL not set in environment');
289
+ }
290
+ if (!refreshToken || typeof refreshToken !== 'string' || refreshToken.length < TOKEN_MIN_LENGTH) {
291
+ throw new Error(`Invalid refresh token${customerIdn ? ` for customer ${customerIdn}` : ''}: must be a non-empty string with minimum length`);
120
292
  }
121
293
 
122
- if (!isExpired(tokens)) {
123
- return tokens.access_token;
294
+ validateUrl(ENV.NEWO_REFRESH_URL, 'NEWO_REFRESH_URL');
295
+
296
+ logAuthEvent('info', 'Refreshing tokens using refresh endpoint', { customerIdn: customerIdn || 'legacy' });
297
+
298
+ try {
299
+ const response = await axios.post<TokenResponse>(
300
+ ENV.NEWO_REFRESH_URL,
301
+ { refresh_token: refreshToken },
302
+ {
303
+ timeout: REQUEST_TIMEOUT,
304
+ headers: {
305
+ 'accept': 'application/json',
306
+ 'user-agent': 'newo-cli/1.5.0'
307
+ }
308
+ }
309
+ );
310
+
311
+ if (!response.data) {
312
+ throw new Error('Empty response from token refresh endpoint');
313
+ }
314
+
315
+ const { access, expiresInSec } = normalizeTokenResponse(response.data);
316
+ const refresh = response.data.refresh_token || response.data.refreshToken || refreshToken;
317
+
318
+ const tokens: StoredTokens = {
319
+ access_token: access,
320
+ refresh_token: refresh,
321
+ expires_at: Date.now() + expiresInSec * 1000
322
+ };
323
+
324
+ // Validate tokens before saving
325
+ validateTokens(tokens);
326
+
327
+ await saveTokens(tokens, customerIdn);
328
+
329
+ logAuthEvent('info', 'Token refresh completed successfully', {
330
+ customerIdn: customerIdn || 'legacy',
331
+ expiresAt: new Date(tokens.expires_at).toISOString()
332
+ });
333
+
334
+ return tokens;
335
+ } catch (error: unknown) {
336
+ logAuthEvent('error', 'Token refresh failed', {
337
+ customerIdn: customerIdn || 'legacy',
338
+ error: error instanceof Error ? error.message : String(error)
339
+ });
340
+ handleNetworkError(error, 'refresh token', customerIdn);
124
341
  }
342
+ }
125
343
 
126
- if (NEWO_REFRESH_URL && tokens.refresh_token) {
127
- try {
128
- tokens = await refreshWithEndpoint(tokens.refresh_token);
344
+ export async function getValidAccessToken(customer?: CustomerConfig): Promise<string> {
345
+ const customerIdn = customer?.idn;
346
+
347
+ logAuthEvent('info', 'Getting valid access token', { customerIdn: customerIdn || 'legacy' });
348
+
349
+ try {
350
+ let tokens = await loadTokens(customerIdn);
351
+
352
+ // No tokens found, exchange API key
353
+ if (!tokens || !tokens.access_token) {
354
+ logAuthEvent('info', 'No existing tokens found, exchanging API key', { customerIdn: customerIdn || 'legacy' });
355
+ tokens = await exchangeApiKeyForToken(customer);
356
+ return tokens.access_token;
357
+ }
358
+
359
+ // Tokens are valid and not expired
360
+ if (!isExpired(tokens)) {
361
+ logAuthEvent('info', 'Using existing valid access token', { customerIdn: customerIdn || 'legacy' });
129
362
  return tokens.access_token;
130
- } catch (error) {
131
- console.warn('Refresh failed, falling back to API key exchange…');
132
363
  }
364
+
365
+ // Try to refresh if refresh URL and token available
366
+ if (ENV.NEWO_REFRESH_URL && tokens.refresh_token) {
367
+ try {
368
+ logAuthEvent('info', 'Attempting to refresh expired token', { customerIdn: customerIdn || 'legacy' });
369
+ tokens = await refreshWithEndpoint(tokens.refresh_token, customer);
370
+ return tokens.access_token;
371
+ } catch (error: unknown) {
372
+ const message = error instanceof Error ? error.message : String(error);
373
+ logAuthEvent('warn', 'Token refresh failed, falling back to API key exchange', {
374
+ customerIdn: customerIdn || 'legacy',
375
+ error: message
376
+ });
377
+ }
378
+ } else {
379
+ logAuthEvent('info', 'No refresh endpoint or refresh token available, using API key exchange', {
380
+ customerIdn: customerIdn || 'legacy'
381
+ });
382
+ }
383
+
384
+ // Fallback to API key exchange
385
+ tokens = await exchangeApiKeyForToken(customer);
386
+ return tokens.access_token;
387
+ } catch (error: unknown) {
388
+ logAuthEvent('error', 'Failed to get valid access token', {
389
+ customerIdn: customerIdn || 'legacy',
390
+ error: error instanceof Error ? error.message : String(error)
391
+ });
392
+ throw new Error(`Unable to obtain valid access token${customerIdn ? ` for customer ${customerIdn}` : ''}: ${error instanceof Error ? error.message : String(error)}`);
133
393
  }
134
-
135
- tokens = await exchangeApiKeyForToken();
136
- return tokens.access_token;
137
394
  }
138
395
 
139
- export async function forceReauth(): Promise<string> {
140
- const tokens = await exchangeApiKeyForToken();
141
- return tokens.access_token;
396
+ export async function forceReauth(customer?: CustomerConfig): Promise<string> {
397
+ const customerIdn = customer?.idn;
398
+
399
+ logAuthEvent('info', 'Forcing re-authentication', { customerIdn: customerIdn || 'legacy' });
400
+
401
+ try {
402
+ const tokens = await exchangeApiKeyForToken(customer);
403
+ logAuthEvent('info', 'Forced re-authentication completed successfully', {
404
+ customerIdn: customerIdn || 'legacy',
405
+ expiresAt: new Date(tokens.expires_at).toISOString()
406
+ });
407
+ return tokens.access_token;
408
+ } catch (error: unknown) {
409
+ logAuthEvent('error', 'Forced re-authentication failed', {
410
+ customerIdn: customerIdn || 'legacy',
411
+ error: error instanceof Error ? error.message : String(error)
412
+ });
413
+ throw error;
414
+ }
142
415
  }