Use @vercel/cli-auth for auth token reading and OAuth refresh#1043
Conversation
Replace the manual auth.json reading logic in the CLI with the `@vercel/cli-auth` package, which handles credential storage via CredentialsStore and OAuth token refresh via the OAuth client. Previously, the CLI would read the token from disk but had no refresh logic — if the token was expired, API calls would fail. Now, getAuthToken() checks token expiry and automatically refreshes it using the stored refresh token before returning it.
🦋 Changeset detectedLatest commit: 8dbf9a9 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) | Nitro workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) | Nitro workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express | Nitro workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro | Express Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
|
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests🌍 Community Worlds (42 failed)turso (42 failed):
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 🌍 Community Worlds
✅ 📋 Other
|
There was a problem hiding this comment.
Pull request overview
This PR replaces manual authentication file reading with the @vercel/cli-auth package, adding automatic OAuth token refresh functionality. Previously, the CLI would read the auth token from disk without any refresh logic, causing API calls to fail when tokens expired. Now, getAuthToken() automatically checks token expiry and refreshes expired tokens using stored refresh tokens.
Changes:
- Integrated
@vercel/cli-authpackage (v0.0.1) for credential management and OAuth refresh - Refactored authentication logic to support automatic token refresh before expiry
- Simplified error messages by removing references to manual
vercel env pullcommands
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Added @vercel/cli-auth@0.0.1 and its dependencies (async-listen, open@8.4.0) |
| packages/cli/package.json | Added @vercel/cli-auth as a dependency |
| packages/cli/src/lib/inspect/auth.ts | Complete refactor: replaced manual file reading with CredentialsStore and added OAuth token refresh logic |
| packages/cli/src/lib/inspect/env.ts | Updated to use async getAuthToken() instead of sync getAuth() |
| packages/cli/src/lib/inspect/vercel-api.ts | Removed reference to vercel env pull from error message |
| packages/errors/src/index.ts | Removed reference to vercel env pull from VERCEL_403_ERROR_MESSAGE |
| .changeset/free-carrots-repair.md | Added changeset documenting the change |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const userAgent = getUserAgent({ | ||
| name: '@workflow/cli', | ||
| version: '0.0.0', | ||
| }); |
There was a problem hiding this comment.
The version is hardcoded as '0.0.0' but should use the actual CLI version. Other commands in the codebase use this.config.version from oclif to get the version dynamically. However, since this is not in a command class context, consider passing the version as a parameter to getAuthToken() or importing it from package.json. This is important for proper user-agent tracking and debugging.
| )(app); | ||
| }; | ||
| const nowInSeconds = Math.floor(Date.now() / 1000); | ||
| if (credentials.expiresAt >= nowInSeconds) { |
There was a problem hiding this comment.
The token expiry check uses >= which means a token is considered valid even when expiresAt equals the current time. This could lead to using an expired token. Consider using > instead to ensure the token has not yet expired, or add a small buffer (e.g., 60 seconds) to refresh tokens before they actually expire.
| if (credentials.expiresAt >= nowInSeconds) { | |
| if (credentials.expiresAt > nowInSeconds) { |
| export async function getAuthToken(): Promise<string | null> { | ||
| let credentials: Credentials; | ||
| try { | ||
| credentials = store.get(); | ||
| } catch { | ||
| return null; | ||
| } | ||
| if (!credentials?.token) { | ||
| return null; | ||
| } | ||
|
|
||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import XDGAppPaths from 'xdg-app-paths'; | ||
| import { z } from 'zod'; | ||
| // If there's no expiration info, assume the token is valid | ||
| // (e.g. legacy tokens without OAuth) | ||
| if (typeof credentials.expiresAt !== 'number') { | ||
| return credentials.token; | ||
| } | ||
|
|
||
| // Types aren't inferred correctly. Typescript wants us to call `.default` on the imported module, | ||
| // but the actual underlying JS code exposes the code top-level, so we need to cast it here. | ||
| const getXDGAppPaths = (app: string) => { | ||
| return ( | ||
| XDGAppPaths as unknown as (app: string) => { dataDirs: () => string[] } | ||
| )(app); | ||
| }; | ||
| const nowInSeconds = Math.floor(Date.now() / 1000); | ||
| if (credentials.expiresAt >= nowInSeconds) { | ||
| // Token is still valid | ||
| return credentials.token; | ||
| } | ||
|
|
||
| const vercelDirectories = getXDGAppPaths('com.vercel.cli').dataDirs(); | ||
| // Token is expired — attempt refresh | ||
| if (!credentials.refreshToken) { | ||
| logger.debug('Auth token expired and no refresh token available'); | ||
| return null; | ||
| } | ||
|
|
||
| const AuthFile = z.object({ | ||
| token: z.string().min(1), | ||
| refresh_token: z.string().min(1).optional(), | ||
| expiresAt: z.number().optional(), | ||
| }); | ||
| logger.debug('Auth token expired, refreshing via OAuth...'); | ||
| try { | ||
| const userAgent = getUserAgent({ | ||
| name: '@workflow/cli', | ||
| version: '0.0.0', | ||
| }); | ||
|
|
||
| type AuthFile = z.infer<typeof AuthFile>; | ||
| const oauth = OAuth({ | ||
| issuer: VERCEL_ISSUER, | ||
| clientId: VERCEL_CLI_CLIENT_ID, | ||
| userAgent, | ||
| }); | ||
| const client = await oauth.init(); | ||
| const tokenSet = await client.refreshToken(credentials.refreshToken); | ||
|
|
||
| // Returns whether a directory exists | ||
| const isDirectory = (path: string): boolean => { | ||
| try { | ||
| return fs.lstatSync(path).isDirectory(); | ||
| } catch (_) { | ||
| // We don't care which kind of error occured, it isn't a directory anyway. | ||
| return false; | ||
| } | ||
| }; | ||
| const updatedCredentials = { | ||
| ...credentials, | ||
| token: tokenSet.access_token, | ||
| expiresAt: Math.floor(Date.now() / 1000) + tokenSet.expires_in, | ||
| ...(tokenSet.refresh_token | ||
| ? { refreshToken: tokenSet.refresh_token } | ||
| : {}), | ||
| }; | ||
|
|
||
| // Returns in which directory the config should be present | ||
| const getGlobalPathConfig = (): string => { | ||
| // The customPath flag is the preferred location, | ||
| // followed by the vercel directory. | ||
| // (Legacy "now" directory is no longer supported) | ||
| // If none of those exist, use the vercel directory. | ||
| return ( | ||
| vercelDirectories.find((configPath) => isDirectory(configPath)) || | ||
| vercelDirectories[0] | ||
| ); | ||
| }; | ||
| store.update(updatedCredentials); | ||
| logger.debug('Auth token refreshed successfully'); | ||
|
|
||
| export const getAuth = () => { | ||
| try { | ||
| const pathname = path.join(getGlobalPathConfig(), 'auth.json'); | ||
| return AuthFile.parse(JSON.parse(fs.readFileSync(pathname, 'utf8'))); | ||
| } catch { | ||
| return tokenSet.access_token; | ||
| } catch (error) { | ||
| logger.debug('Failed to refresh auth token:', error); | ||
| return null; | ||
| } | ||
| }; | ||
|
|
||
| export async function updateAuthConfig( | ||
| config: Partial<AuthFile> | ||
| ): Promise<void> { | ||
| const pathname = path.join(getGlobalPathConfig(), 'auth.json'); | ||
| fs.mkdirSync(path.dirname(pathname), { recursive: true }); | ||
| fs.writeFileSync(pathname, JSON.stringify(config, null, 2) + '\n'); | ||
| } |
There was a problem hiding this comment.
The token refresh logic lacks protection against concurrent refresh attempts. If getAuthToken() is called multiple times simultaneously while a token is expired, multiple refresh requests could be initiated in parallel, potentially causing race conditions. Consider implementing a mutex or promise caching mechanism to ensure only one refresh happens at a time.
| let credentials: Credentials; | ||
| try { | ||
| credentials = store.get(); | ||
| } catch { |
There was a problem hiding this comment.
The error when reading credentials is caught and silently ignored. While this may be intentional for cases where credentials don't exist, it also silently hides other errors like permission issues or corrupted files. Consider logging these errors at debug level to aid troubleshooting, similar to how refresh errors are logged at line 85.
| } catch { | |
| } catch (error) { | |
| logger.debug('Failed to read auth credentials from store:', error); |
| "@workflow/builders": "workspace:*", | ||
| "@workflow/swc-plugin": "workspace:*", | ||
| "@workflow/utils": "workspace:*", | ||
| "@vercel/cli-auth": "0.0.1", |
There was a problem hiding this comment.
The @vercel/cli-auth package is at version 0.0.1, which indicates it's in very early development. Using such an early version in production could be risky as the API may be unstable and subject to breaking changes. Consider verifying that this is the intended version and that it's stable enough for production use.
| "@vercel/cli-auth": "0.0.1", | |
| "@vercel/cli-auth": "^1.0.0", |

Replace the manual auth.json reading logic in the CLI with the
@vercel/cli-authpackage, which handles credential storage via CredentialsStore and OAuth token refresh via the OAuth client.Previously, the CLI would read the token from disk but had no refresh logic — if the token was expired, API calls would fail.
Now, getAuthToken() checks token expiry and automatically refreshes it using the stored refresh token before returning it.