@vercel/cli-auth 0.1.0-canary.20260211174907.cdd2da6 → 0.1.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.
@@ -1,14 +1,63 @@
1
1
  import path from 'node:path';
2
+ import { createHash, randomUUID } from 'node:crypto';
2
3
  import * as fs from 'node:fs';
3
4
  import { homedir } from 'node:os';
4
5
  // NOTE: We are pinned on v5 because newer versions behave differently
5
6
  // when there are dots in the application name, eg. `com.vercel.cli` becomes `com.vercel`
6
7
  import XDGAppPaths from 'xdg-app-paths';
7
- import { z } from 'zod/mini';
8
8
 
9
- const Credentials = z.object({
9
+ // Avoid exposing zod v4 types to TS 4.9 workspace consumers during type-check.
10
+ const { z } = require('zod/mini') as { z: any };
11
+
12
+ const AUTH_TOKEN_STORAGE = {
13
+ AUTO: 'auto',
14
+ FILE: 'file',
15
+ KEYRING: 'keyring',
16
+ } as const;
17
+ const AUTH_TOKEN_STORAGE_VALUES = Object.values(AUTH_TOKEN_STORAGE);
18
+ const DEFAULT_AUTH_TOKEN_STORAGE = AUTH_TOKEN_STORAGE.FILE;
19
+ const KEYRING_SERVICE = 'Vercel Auth';
20
+ const KEYRING_UNAVAILABLE_ERROR =
21
+ 'OS keyring support is unavailable. Set `authTokenStorage` to `auto` or `file` to store credentials in auth.json instead.';
22
+
23
+ type KeyringEntry = {
24
+ getPassword(): string | null;
25
+ setPassword(password: string): void;
26
+ deleteCredential(): boolean;
27
+ };
28
+
29
+ type KeyringEntryConstructor = new (
30
+ service: string,
31
+ account: string
32
+ ) => KeyringEntry;
33
+
34
+ type LoadKeyringModule = () => {
35
+ Entry: KeyringEntryConstructor;
36
+ };
37
+
38
+ const defaultLoadKeyringModule: LoadKeyringModule = () =>
39
+ require('@napi-rs/keyring') as {
40
+ Entry: KeyringEntryConstructor;
41
+ };
42
+
43
+ let loadKeyringModule = defaultLoadKeyringModule;
44
+
45
+ export type AuthTokenStorage =
46
+ (typeof AUTH_TOKEN_STORAGE)[keyof typeof AUTH_TOKEN_STORAGE];
47
+ export type CredentialsStorageLocation = Exclude<
48
+ AuthTokenStorage,
49
+ (typeof AUTH_TOKEN_STORAGE)['AUTO']
50
+ >;
51
+
52
+ export interface GlobalConfig {
53
+ authTokenStorage?: AuthTokenStorage;
54
+ }
55
+
56
+ const CredentialsSchema = z.object({
10
57
  /** An `access_token` obtained using the OAuth Device Authorization flow. */
11
58
  token: z.optional(z.string()),
59
+ /** The ID of the currently authenticated user, cached from `/v2/user`. */
60
+ userId: z.optional(z.string()),
12
61
  /** A `refresh_token` obtained using the OAuth Device Authorization flow. */
13
62
  refreshToken: z.optional(z.string()),
14
63
  /**
@@ -22,7 +71,56 @@ const Credentials = z.object({
22
71
  '// Docs': z.optional(z.string()),
23
72
  });
24
73
 
25
- export type Credentials = z.infer<typeof Credentials>;
74
+ export interface Credentials {
75
+ /** An `access_token` obtained using the OAuth Device Authorization flow. */
76
+ token?: string;
77
+ /** A `refresh_token` obtained using the OAuth Device Authorization flow. */
78
+ refreshToken?: string;
79
+ /**
80
+ * The absolute time (seconds) when the {@link Credentials.token} expires.
81
+ * Used to optimistically check if the token is still valid.
82
+ */
83
+ expiresAt?: number;
84
+ /** Whether to skip writing the credentials to disk during {@link CredentialsStore.update} */
85
+ skipWrite?: boolean;
86
+ '// Note'?: string;
87
+ '// Docs'?: string;
88
+ }
89
+
90
+ export function hasCredentials(credentials: Partial<Credentials>): boolean {
91
+ return Boolean(
92
+ credentials.token ||
93
+ credentials.refreshToken ||
94
+ typeof credentials.expiresAt === 'number'
95
+ );
96
+ }
97
+
98
+ function parseCredentials(value: unknown): Credentials {
99
+ return CredentialsSchema.parse(value) as Credentials;
100
+ }
101
+
102
+ function isAuthTokenStorage(value: unknown): value is AuthTokenStorage {
103
+ return (
104
+ typeof value === 'string' &&
105
+ AUTH_TOKEN_STORAGE_VALUES.some(storage => storage === value)
106
+ );
107
+ }
108
+
109
+ export function resolveAuthTokenStorage(value: unknown): AuthTokenStorage {
110
+ if (typeof value === 'undefined') {
111
+ return DEFAULT_AUTH_TOKEN_STORAGE;
112
+ }
113
+
114
+ if (isAuthTokenStorage(value)) {
115
+ return value;
116
+ }
117
+
118
+ throw new Error(
119
+ `Invalid value for \`authTokenStorage\`: ${JSON.stringify(
120
+ value
121
+ )}. Expected one of: ${AUTH_TOKEN_STORAGE_VALUES.map(storage => JSON.stringify(storage)).join(', ')}.`
122
+ );
123
+ }
26
124
 
27
125
  /** Returns whether a directory exists */
28
126
  function isDirectory(path: string): boolean {
@@ -38,8 +136,8 @@ function isDirectory(path: string): boolean {
38
136
  * Returns in which directory the config should be present
39
137
  * @internal Should only be used in {@link CredentialsStore} or tests.
40
138
  */
41
- export function getGlobalPathConfig(dir: string): string {
42
- const vercelDirectories = XDGAppPaths(dir).dataDirs();
139
+ export function getGlobalPathConfig(appName: string): string {
140
+ const vercelDirectories = XDGAppPaths(appName).dataDirs();
43
141
 
44
142
  const possibleConfigPaths = [
45
143
  ...vercelDirectories, // latest vercel directory
@@ -57,23 +155,403 @@ export function getGlobalPathConfig(dir: string): string {
57
155
  );
58
156
  }
59
157
 
60
- export function CredentialsStore(dir: string) {
61
- const configPath = path.join(getGlobalPathConfig(dir), 'auth.json');
158
+ export function getConfigFilePath(dir: string): string {
159
+ return path.join(dir, 'config.json');
160
+ }
161
+
162
+ export function getAuthConfigFilePath(dir: string): string {
163
+ return path.join(dir, 'auth.json');
164
+ }
165
+
166
+ export function readGlobalConfig(dir: string): GlobalConfig {
167
+ const configPath = getConfigFilePath(dir);
168
+ let config: {
169
+ authTokenStorage?: unknown;
170
+ };
171
+
172
+ try {
173
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
174
+ authTokenStorage?: unknown;
175
+ };
176
+ } catch (error) {
177
+ if (isENOENTError(error)) {
178
+ return {};
179
+ }
180
+
181
+ throw error;
182
+ }
183
+
184
+ return {
185
+ authTokenStorage:
186
+ typeof config.authTokenStorage === 'undefined'
187
+ ? undefined
188
+ : resolveAuthTokenStorage(config.authTokenStorage),
189
+ };
190
+ }
191
+
192
+ export function getAuthTokenStorage(
193
+ dir: string,
194
+ config?: GlobalConfig
195
+ ): AuthTokenStorage {
196
+ if (config?.authTokenStorage) {
197
+ return resolveAuthTokenStorage(config.authTokenStorage);
198
+ }
199
+
200
+ return resolveAuthTokenStorage(readGlobalConfig(dir).authTokenStorage);
201
+ }
202
+
203
+ function getKeyringAccount(configPath: string): string {
204
+ const digest = createHash('sha256').update(configPath).digest('hex');
205
+ return `cli:${digest.slice(0, 16)}`;
206
+ }
207
+
208
+ export function setLoadKeyringModuleForTesting(
209
+ nextLoadKeyringModule?: LoadKeyringModule
210
+ ) {
211
+ loadKeyringModule = nextLoadKeyringModule ?? defaultLoadKeyringModule;
212
+ }
213
+
214
+ function createKeyringEntry(configPath: string): KeyringEntry {
215
+ try {
216
+ const { Entry } = loadKeyringModule();
217
+
218
+ return new Entry(KEYRING_SERVICE, getKeyringAccount(configPath));
219
+ } catch (error) {
220
+ const wrappedError = new Error(KEYRING_UNAVAILABLE_ERROR);
221
+ (wrappedError as Error & { cause?: unknown }).cause = error;
222
+ throw wrappedError;
223
+ }
224
+ }
225
+
226
+ function isKeyringUnavailableError(error: unknown): boolean {
227
+ return error instanceof Error && error.message === KEYRING_UNAVAILABLE_ERROR;
228
+ }
229
+
230
+ function readCredentialsFromFile(configPath: string): Credentials {
231
+ return parseCredentials(JSON.parse(fs.readFileSync(configPath, 'utf8')));
232
+ }
233
+
234
+ function isENOENTError(error: unknown): boolean {
235
+ return (
236
+ typeof error === 'object' &&
237
+ error !== null &&
238
+ 'code' in error &&
239
+ error.code === 'ENOENT'
240
+ );
241
+ }
242
+
243
+ function createMissingCredentialsError(configPath: string): Error {
244
+ const error = new Error(
245
+ `ENOENT: no such file or directory, open '${configPath}'`
246
+ );
247
+ Object.assign(error, { code: 'ENOENT' });
248
+ return error;
249
+ }
250
+
251
+ function writeCredentialsToFile(configPath: string, config: Credentials): void {
252
+ const directory = path.dirname(configPath);
253
+ const temporaryPath = path.join(
254
+ directory,
255
+ `${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`
256
+ );
257
+
258
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
259
+
260
+ try {
261
+ fs.writeFileSync(temporaryPath, JSON.stringify(config, null, 2) + '\n', {
262
+ mode: 0o600,
263
+ });
264
+ fs.renameSync(temporaryPath, configPath);
265
+ } catch (error) {
266
+ fs.rmSync(temporaryPath, { force: true });
267
+ throw error;
268
+ }
269
+ }
270
+
271
+ function readCredentialsFromKeyring(configPath: string): Credentials | null {
272
+ const entry = createKeyringEntry(configPath);
273
+ const value = entry.getPassword();
274
+
275
+ if (value === null) {
276
+ return null;
277
+ }
278
+
279
+ return parseCredentials(JSON.parse(value));
280
+ }
281
+
282
+ function writeCredentialsToKeyring(
283
+ configPath: string,
284
+ config: Credentials
285
+ ): void {
286
+ const entry = createKeyringEntry(configPath);
287
+ entry.setPassword(JSON.stringify(config));
288
+ }
289
+
290
+ function deleteCredentialsFromKeyring(configPath: string): boolean {
291
+ const entry = createKeyringEntry(configPath);
292
+ return entry.deleteCredential();
293
+ }
294
+
295
+ function deleteCredentialsFile(configPath: string): void {
296
+ fs.rmSync(configPath, { force: true });
297
+ }
298
+
299
+ function tryDeleteCredentialsFromKeyring(configPath: string): void {
300
+ try {
301
+ deleteCredentialsFromKeyring(configPath);
302
+ } catch {}
303
+ }
304
+
305
+ function deleteCredentialsFromKeyringIfAvailable(configPath: string): void {
306
+ try {
307
+ deleteCredentialsFromKeyring(configPath);
308
+ } catch (error) {
309
+ if (!isKeyringUnavailableError(error)) {
310
+ throw error;
311
+ }
312
+ }
313
+ }
314
+
315
+ function migrateCredentialsToFile(
316
+ configPath: string,
317
+ credentials: Credentials
318
+ ): Credentials {
319
+ writeCredentialsToFile(configPath, credentials);
320
+ tryDeleteCredentialsFromKeyring(configPath);
321
+ return credentials;
322
+ }
323
+
324
+ function tryReadKeyringCredentialsIntoFile(
325
+ configPath: string
326
+ ): Credentials | null {
327
+ try {
328
+ const credentials = readCredentialsFromKeyring(configPath);
329
+
330
+ if (credentials === null) {
331
+ return null;
332
+ }
333
+
334
+ return migrateCredentialsToFile(configPath, credentials);
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
340
+ function readFileStorageCredentials(configPath: string): Credentials {
341
+ try {
342
+ const credentials = readCredentialsFromFile(configPath);
343
+
344
+ if (hasCredentials(credentials)) {
345
+ return credentials;
346
+ }
347
+
348
+ // File mode treats keyring contents as stale-but-authoritative backup only
349
+ // when auth.json has no usable credentials.
350
+ return tryReadKeyringCredentialsIntoFile(configPath) ?? credentials;
351
+ } catch (error) {
352
+ if (!isENOENTError(error)) {
353
+ throw error;
354
+ }
355
+
356
+ const credentials = tryReadKeyringCredentialsIntoFile(configPath);
357
+
358
+ if (credentials !== null) {
359
+ return credentials;
360
+ }
361
+
362
+ throw error;
363
+ }
364
+ }
365
+
366
+ function migrateCredentialsToKeyring(
367
+ configPath: string,
368
+ credentials: Credentials
369
+ ): Credentials {
370
+ writeCredentialsToKeyring(configPath, credentials);
371
+ deleteCredentialsFile(configPath);
372
+ return credentials;
373
+ }
374
+
375
+ function readKeyringStorageCredentials(configPath: string): Credentials {
376
+ const credentials = readCredentialsFromKeyring(configPath);
377
+
378
+ if (credentials !== null) {
379
+ return credentials;
380
+ }
381
+
382
+ try {
383
+ const fileCredentials = readCredentialsFromFile(configPath);
384
+
385
+ // Keyring mode eagerly migrates any remaining plaintext credentials out of
386
+ // auth.json after the first successful read.
387
+ return migrateCredentialsToKeyring(configPath, fileCredentials);
388
+ } catch (error) {
389
+ if (isENOENTError(error)) {
390
+ throw createMissingCredentialsError(configPath);
391
+ }
392
+
393
+ throw error;
394
+ }
395
+ }
396
+
397
+ function readAutoStorageCredentials(configPath: string): Credentials {
398
+ try {
399
+ const credentials = readCredentialsFromKeyring(configPath);
400
+
401
+ if (credentials !== null) {
402
+ return credentials;
403
+ }
404
+ } catch {}
405
+
406
+ // Auto mode intentionally avoids read-time migration so that machines
407
+ // without usable keyring support can keep working off auth.json.
408
+ return readCredentialsFromFile(configPath);
409
+ }
410
+
411
+ function writeFileStorageCredentials(
412
+ configPath: string,
413
+ credentials: Credentials
414
+ ): CredentialsStorageLocation {
415
+ writeCredentialsToFile(configPath, credentials);
416
+ tryDeleteCredentialsFromKeyring(configPath);
417
+ return 'file';
418
+ }
419
+
420
+ function writeKeyringStorageCredentials(
421
+ configPath: string,
422
+ credentials: Credentials
423
+ ): CredentialsStorageLocation {
424
+ writeCredentialsToKeyring(configPath, credentials);
425
+ deleteCredentialsFile(configPath);
426
+ return 'keyring';
427
+ }
428
+
429
+ function writeAutoStorageCredentials(
430
+ configPath: string,
431
+ credentials: Credentials
432
+ ): CredentialsStorageLocation {
433
+ try {
434
+ return writeKeyringStorageCredentials(configPath, credentials);
435
+ } catch {
436
+ tryDeleteCredentialsFromKeyring(configPath);
437
+ return writeFileStorageCredentials(configPath, credentials);
438
+ }
439
+ }
440
+
441
+ function deleteFileStorageCredentials(configPath: string): void {
442
+ deleteCredentialsFile(configPath);
443
+ deleteCredentialsFromKeyringIfAvailable(configPath);
444
+ }
445
+
446
+ function deleteKeyringStorageCredentials(configPath: string): void {
447
+ deleteCredentialsFromKeyring(configPath);
448
+ deleteCredentialsFile(configPath);
449
+ }
450
+
451
+ function deleteAutoStorageCredentials(configPath: string): void {
452
+ try {
453
+ deleteCredentialsFromKeyring(configPath);
454
+ } catch {}
455
+ deleteCredentialsFile(configPath);
456
+ }
457
+
458
+ export function CredentialsStore(dir: string, config: GlobalConfig = {}) {
459
+ const configPath = getAuthConfigFilePath(dir);
460
+ const tokenStorage = getAuthTokenStorage(dir, config);
461
+
62
462
  return {
63
463
  configPath,
64
464
  get(): Credentials {
65
- return Credentials.parse(JSON.parse(fs.readFileSync(configPath, 'utf8')));
465
+ switch (tokenStorage) {
466
+ case AUTH_TOKEN_STORAGE.FILE:
467
+ return readFileStorageCredentials(configPath);
468
+ case AUTH_TOKEN_STORAGE.KEYRING:
469
+ return readKeyringStorageCredentials(configPath);
470
+ case AUTH_TOKEN_STORAGE.AUTO:
471
+ return readAutoStorageCredentials(configPath);
472
+ }
66
473
  },
67
474
  /** Update the credentials store. If `skipWrite` is set, the update will be skipped. */
68
- update(config: Partial<Credentials>): void {
475
+ update(config: Partial<Credentials>): CredentialsStorageLocation | void {
69
476
  if (config.skipWrite) return;
70
- const parsedConfig = Credentials.parse(config);
71
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
72
- fs.writeFileSync(
73
- configPath,
74
- JSON.stringify(parsedConfig, null, 2) + '\n',
75
- { mode: 0o600 }
76
- );
477
+ const parsedConfig = parseCredentials(config);
478
+ switch (tokenStorage) {
479
+ case AUTH_TOKEN_STORAGE.FILE:
480
+ return writeFileStorageCredentials(configPath, parsedConfig);
481
+ case AUTH_TOKEN_STORAGE.KEYRING:
482
+ return writeKeyringStorageCredentials(configPath, parsedConfig);
483
+ case AUTH_TOKEN_STORAGE.AUTO:
484
+ return writeAutoStorageCredentials(configPath, parsedConfig);
485
+ }
486
+ },
487
+ delete(): void {
488
+ switch (tokenStorage) {
489
+ case AUTH_TOKEN_STORAGE.FILE:
490
+ deleteFileStorageCredentials(configPath);
491
+ break;
492
+ case AUTH_TOKEN_STORAGE.KEYRING:
493
+ deleteKeyringStorageCredentials(configPath);
494
+ break;
495
+ case AUTH_TOKEN_STORAGE.AUTO:
496
+ deleteAutoStorageCredentials(configPath);
497
+ break;
498
+ }
77
499
  },
78
500
  };
79
501
  }
502
+
503
+ export function readCredentials(dir: string, config: GlobalConfig = {}) {
504
+ return CredentialsStore(dir, config).get();
505
+ }
506
+
507
+ export function writeCredentials(
508
+ dir: string,
509
+ credentials: Partial<Credentials>,
510
+ config: GlobalConfig = {}
511
+ ) {
512
+ return CredentialsStore(dir, config).update(credentials);
513
+ }
514
+
515
+ export function clearCredentials(dir: string, config: GlobalConfig = {}) {
516
+ CredentialsStore(dir, config).delete();
517
+ }
518
+
519
+ export function clearAllCredentials(dir: string) {
520
+ for (const authTokenStorage of [
521
+ AUTH_TOKEN_STORAGE.FILE,
522
+ AUTH_TOKEN_STORAGE.KEYRING,
523
+ ] as const) {
524
+ try {
525
+ clearCredentials(dir, { authTokenStorage });
526
+ } catch {}
527
+ }
528
+ }
529
+
530
+ export function clearAllCredentialsStrict(dir: string) {
531
+ const configPath = getAuthConfigFilePath(dir);
532
+
533
+ let fileError: unknown;
534
+ let keyringError: unknown;
535
+
536
+ try {
537
+ deleteCredentialsFile(configPath);
538
+ } catch (error) {
539
+ fileError = error;
540
+ }
541
+
542
+ try {
543
+ deleteCredentialsFromKeyring(configPath);
544
+ } catch (error) {
545
+ if (!isKeyringUnavailableError(error)) {
546
+ keyringError = error;
547
+ }
548
+ }
549
+
550
+ if (keyringError) {
551
+ throw keyringError;
552
+ }
553
+
554
+ if (fileError) {
555
+ throw fileError;
556
+ }
557
+ }