diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 37557d2..8880973 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -46,7 +46,7 @@ "@types/debug": "^4.1.12", "@types/ms": "^2.1.0", "@types/node": "^22.15.12", - "@vercel/oidc": "^3.1.0", + "@vercel/oidc": "file:../../../vercel/packages/oidc", "@vercel/pty-tunnel": "workspace:*", "@vercel/pty-tunnel-server": "workspace:*", "@vercel/sandbox": "workspace:*", diff --git a/packages/sandbox/src/args/auth.ts b/packages/sandbox/src/args/auth.ts index 1ed1138..9e11d73 100644 --- a/packages/sandbox/src/args/auth.ts +++ b/packages/sandbox/src/args/auth.ts @@ -3,12 +3,11 @@ import { login } from "../commands/login"; import createDebugger from "debug"; import chalk from "chalk"; import { - getAuth, - updateAuthConfig, - isOAuthError, - OAuth, -} from "@vercel/sandbox/dist/auth/index.js"; -import { getVercelOidcToken } from "@vercel/oidc"; + getVercelOidcToken, + getVercelCliToken, + AccessTokenMissingError, + RefreshAccessTokenFailedError, +} from "@vercel/oidc"; const debug = createDebugger("sandbox:args:auth"); @@ -38,79 +37,46 @@ export const token = cmd.option({ } } - let auth = getAuth(); - - // If there's no auth token, run the login command - if (!auth) { - await login.handler({}); - auth = getAuth(); - } - - if (auth) { - const refreshed = await refreshToken(auth); - if (typeof refreshed === "object") { - auth = refreshed; - } else if ( - refreshed === "missing refresh token" || - refreshed === "invalid refresh token" + // Try to get CLI token, which handles auth.json reading and refresh + try { + return await getVercelCliToken(); + } catch (error) { + // Handle specific auth errors by triggering login + if ( + error instanceof AccessTokenMissingError || + error instanceof RefreshAccessTokenFailedError ) { + debug( + `CLI token unavailable (${error.name}), prompting for login...`, + ); console.warn( chalk.yellow( `${chalk.bold("notice:")} Your session has expired. Please log in again.`, ), ); await login.handler({}); - auth = getAuth(); + + // Try again after login + try { + return await getVercelCliToken(); + } catch (retryError) { + throw new Error( + [ + `Failed to retrieve authentication token.`, + `${chalk.bold("hint:")} Try logging in again with \`sandbox login\`.`, + "╰▶ Docs: https://vercel.com/docs/vercel-sandbox/cli-reference#authentication", + ].join("\n"), + ); + } } - } - if (!auth || !auth.token) { - throw new Error( - [ - `Failed to retrieve authentication token.`, - `${chalk.bold("hint:")} Try logging in again with \`sandbox login\`.`, - "╰▶ Docs: https://vercel.com/docs/vercel-sandbox/cli-reference#authentication", - ].join("\n"), - ); + // Re-throw unexpected errors + throw error; } - - return auth.token; }, }, }); -async function refreshToken(file: NonNullable>) { - if (!file.expiresAt) return; - if (file.expiresAt.getTime() > Date.now()) { - return "not expired" as const; - } - - if (!file.refreshToken) { - debug(`Token expired, yet refresh token unavailable.`); - return "missing refresh token" as const; - } - - debug(`Refreshing token (expired at ${file.expiresAt.toISOString()})`); - const oauth = await OAuth(); - const newToken = await oauth.refreshToken(file.refreshToken).catch((err) => { - if (isOAuthError(err)) { - return null; - } - throw err; - }); - if (!newToken) { - return "invalid refresh token" as const; - } - updateAuthConfig({ - expiresAt: new Date(Date.now() + newToken.expires_in * 1000), - token: newToken.access_token, - refreshToken: newToken.refresh_token || file.refreshToken, - }); - const updated = getAuth(); - debug(`Token stored. expires at ${updated?.expiresAt?.toISOString()})`); - return updated; -} - function getMessage(error: unknown): string { if (!(error instanceof Error)) { return String(error); diff --git a/packages/sandbox/test/args/auth.test.ts b/packages/sandbox/test/args/auth.test.ts index bdfbc5b..27d5b37 100644 --- a/packages/sandbox/test/args/auth.test.ts +++ b/packages/sandbox/test/args/auth.test.ts @@ -1,87 +1,124 @@ -import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, test, expect, vi, beforeEach } from "vitest"; import * as cmd from "cmd-ts"; -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; - -vi.mock("@vercel/oidc", () => ({ - getVercelOidcToken: vi.fn(), -})); +import { + AccessTokenMissingError, + RefreshAccessTokenFailedError, +} from "@vercel/oidc"; + +const { mockGetVercelCliToken, mockGetVercelOidcToken, mockLogin } = + vi.hoisted(() => ({ + mockGetVercelCliToken: vi.fn(), + mockGetVercelOidcToken: vi.fn(), + mockLogin: vi.fn(), + })); + +vi.mock("@vercel/oidc", async () => { + const actual = await vi.importActual( + "@vercel/oidc", + ); + return { + ...actual, + getVercelCliToken: mockGetVercelCliToken, + getVercelOidcToken: mockGetVercelOidcToken, + }; +}); vi.mock("../../src/commands/login", () => ({ - login: { handler: vi.fn() }, + login: { handler: mockLogin }, })); describe("token", () => { - let tmpDir: string; - let fetchMock: ReturnType; - beforeEach(() => { vi.resetModules(); + vi.clearAllMocks(); // Clear relevant env vars delete process.env.VERCEL_AUTH_TOKEN; delete process.env.VERCEL_OIDC_TOKEN; + }); + + describe("environment variable fallbacks", () => { + test("uses VERCEL_AUTH_TOKEN when set", async () => { + process.env.VERCEL_AUTH_TOKEN = "env-auth-token"; + + const { token } = await import("../../src/args/auth.ts"); + + const command = cmd.command({ + name: "test", + args: { token }, + handler: (args) => args, + }); - // Create temp dir for auth config BEFORE any imports - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sandbox-auth-test-")); - process.env.VERCEL_AUTH_CONFIG_DIR = tmpDir; - - // Mock fetch for OAuth calls - fetchMock = vi.fn().mockImplementation(async (url: URL | string) => { - const urlStr = url.toString(); - - // OIDC discovery endpoint - if (urlStr.includes(".well-known/openid-configuration")) { - return Response.json({ - issuer: "https://vercel.com", - device_authorization_endpoint: "https://vercel.com/oauth/device", - token_endpoint: "https://vercel.com/oauth/token", - revocation_endpoint: "https://vercel.com/oauth/revoke", - jwks_uri: "https://vercel.com/.well-known/jwks.json", - introspection_endpoint: "https://vercel.com/oauth/introspect", - }); - } - - // Token refresh endpoint - if (urlStr.includes("/oauth/token")) { - return Response.json({ - access_token: "new-refreshed-token", - token_type: "Bearer", - expires_in: 3600, - refresh_token: "new-refresh-token", - }); - } - - throw new Error(`Unexpected fetch: ${urlStr}`); + const result = await cmd.run(command, []); + + expect(result.token).toBe("env-auth-token"); + expect(mockGetVercelCliToken).not.toHaveBeenCalled(); }); - vi.stubGlobal("fetch", fetchMock); - }); - afterEach(() => { - delete process.env.VERCEL_AUTH_TOKEN; - delete process.env.VERCEL_OIDC_TOKEN; - delete process.env.VERCEL_AUTH_CONFIG_DIR; - vi.unstubAllGlobals(); + test("uses VERCEL_OIDC_TOKEN and calls getVercelOidcToken when set", async () => { + process.env.VERCEL_OIDC_TOKEN = "existing-oidc-token"; + mockGetVercelOidcToken.mockResolvedValue("refreshed-oidc-token"); + + const { token } = await import("../../src/args/auth.ts"); + + const command = cmd.command({ + name: "test", + args: { token }, + handler: (args) => args, + }); + + const result = await cmd.run(command, []); + + expect(result.token).toBe("refreshed-oidc-token"); + expect(mockGetVercelOidcToken).toHaveBeenCalled(); + expect(mockGetVercelCliToken).not.toHaveBeenCalled(); + }); + + test("falls back to getVercelCliToken when no env vars set", async () => { + mockGetVercelCliToken.mockResolvedValue("cli-token"); + + const { token } = await import("../../src/args/auth.ts"); + + const command = cmd.command({ + name: "test", + args: { token }, + handler: (args) => args, + }); + + const result = await cmd.run(command, []); - // Clean up temp dir - if (tmpDir) { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } + expect(result.token).toBe("cli-token"); + expect(mockGetVercelCliToken).toHaveBeenCalled(); + }); }); - describe("refreshToken behavior", () => { - test("refreshes expired token and returns the new one", async () => { - // Token that is ALREADY expired - const authFile = { - token: "expired-old-token", - expiresAt: Math.floor((Date.now() - 60000) / 1000), // expired 1 minute ago - refreshToken: "valid-refresh-token", - }; - fs.writeFileSync( - path.join(tmpDir, "auth.json"), - JSON.stringify(authFile) + "\n", - ); + describe("error handling", () => { + test("triggers login when getVercelCliToken throws AccessTokenMissingError", async () => { + mockGetVercelCliToken + .mockRejectedValueOnce(new AccessTokenMissingError()) + .mockResolvedValueOnce("token-after-login"); + mockLogin.mockResolvedValue(undefined); + + const { token } = await import("../../src/args/auth.ts"); + + const command = cmd.command({ + name: "test", + args: { token }, + handler: (args) => args, + }); + + const result = await cmd.run(command, []); + + expect(mockLogin).toHaveBeenCalled(); + expect(mockGetVercelCliToken).toHaveBeenCalledTimes(2); + expect(result.token).toBe("token-after-login"); + }); + + test("triggers login when getVercelCliToken throws RefreshAccessTokenFailedError", async () => { + mockGetVercelCliToken + .mockRejectedValueOnce(new RefreshAccessTokenFailedError()) + .mockResolvedValueOnce("token-after-login"); + mockLogin.mockResolvedValue(undefined); const { token } = await import("../../src/args/auth.ts"); @@ -93,8 +130,9 @@ describe("token", () => { const result = await cmd.run(command, []); - // Should return the refreshed token, not the expired one - expect(result.token).toBe("new-refreshed-token"); + expect(mockLogin).toHaveBeenCalled(); + expect(mockGetVercelCliToken).toHaveBeenCalledTimes(2); + expect(result.token).toBe("token-after-login"); }); }); }); diff --git a/packages/vercel-sandbox/package.json b/packages/vercel-sandbox/package.json index 640deed..3372807 100644 --- a/packages/vercel-sandbox/package.json +++ b/packages/vercel-sandbox/package.json @@ -30,7 +30,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@vercel/oidc": "^3.1.0", + "@vercel/oidc": "file:../../../vercel/packages/oidc", "async-retry": "1.3.3", "jsonlines": "0.1.1", "ms": "2.1.3", diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index 946ad4a..0c48492 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -30,7 +30,8 @@ import jsonlines from "jsonlines"; import os from "os"; import { Readable } from "stream"; import { normalizePath } from "../utils/normalizePath"; -import { JwtExpiry } from "../utils/jwt-expiry"; +import { getVercelOidcToken } from "@vercel/oidc"; +import ms from "ms"; import { getPrivateParams, WithPrivate } from "../utils/types"; import { RUNTIMES } from "../constants"; @@ -47,7 +48,8 @@ export interface APINetworkPolicy { export class APIClient extends BaseClient { private teamId: string; - private tokenExpiry: JwtExpiry | null; + private projectId: string | undefined; + private isJwtToken: boolean; constructor(params: { baseUrl?: string; @@ -63,23 +65,55 @@ export class APIClient extends BaseClient { }); this.teamId = params.teamId; - this.tokenExpiry = JwtExpiry.fromToken(params.token); + this.isJwtToken = params.token.split(".").length === 3; + + // Extract projectId from JWT token if available + if (this.isJwtToken) { + try { + const payload = JSON.parse( + Buffer.from(params.token.split(".")[1], "base64url").toString("utf8") + ); + this.projectId = payload.project_id; + // Update teamId from token if available + if (payload.owner_id) { + this.teamId = payload.owner_id; + } + } catch { + // Ignore parse errors + } + } } private async ensureValidToken(): Promise { - if (!this.tokenExpiry) { + if (!this.isJwtToken) { return; } - const newExpiry = await this.tokenExpiry.tryRefresh(); - if (!newExpiry) { - return; - } + try { + const freshToken = await getVercelOidcToken({ + expirationBufferMs: ms("5m"), + teamId: this.teamId, + projectId: this.projectId, + }); + + // Update token if it changed + if (freshToken !== this.token) { + this.token = freshToken; - this.tokenExpiry = newExpiry; - this.token = this.tokenExpiry.token; - if (this.tokenExpiry.payload) { - this.teamId = this.tokenExpiry.payload?.owner_id; + // Update teamId from refreshed token + try { + const payload = JSON.parse( + Buffer.from(freshToken.split(".")[1], "base64url").toString("utf8") + ); + if (payload.owner_id) { + this.teamId = payload.owner_id; + } + } catch { + // Ignore parse errors + } + } + } catch { + // Ignore refresh errors and continue with current token } } diff --git a/packages/vercel-sandbox/src/utils/get-credentials.ts b/packages/vercel-sandbox/src/utils/get-credentials.ts index 5488f1f..341d5ea 100644 --- a/packages/vercel-sandbox/src/utils/get-credentials.ts +++ b/packages/vercel-sandbox/src/utils/get-credentials.ts @@ -61,7 +61,14 @@ async function getVercelToken(opts: { projectId?: string; }): Promise { try { - return getCredentialsFromOIDCToken(await getVercelOidcToken()); + // Pass teamId and projectId to getVercelOidcToken to enable token refresh + // without needing to read from .vercel/project.json. This is useful when + // these values are already known from previous calls or user input. + const token = await getVercelOidcToken({ + teamId: opts.teamId, + projectId: opts.projectId, + }); + return getCredentialsFromOIDCToken(token); } catch (error) { if (!shouldPromptForCredentials()) { if (process.env.VERCEL_URL) { diff --git a/packages/vercel-sandbox/src/utils/jwt-expiry.test.ts b/packages/vercel-sandbox/src/utils/jwt-expiry.test.ts deleted file mode 100644 index 66c9bb7..0000000 --- a/packages/vercel-sandbox/src/utils/jwt-expiry.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { assert, beforeEach, describe, expect, test, vi } from "vitest"; -import { JwtExpiry } from "./jwt-expiry"; -import type { getVercelOidcToken } from "@vercel/oidc"; - -const { getVercelOidcTokenMock } = vi.hoisted(() => { - return { - getVercelOidcTokenMock: vi.fn(), - }; -}); -vi.mock("@vercel/oidc", () => ({ - getVercelOidcToken: getVercelOidcTokenMock, -})); -beforeEach(() => { - getVercelOidcTokenMock.mockReset(); -}); - -describe("JwtExpiry", () => { - test("refreshes a token", async () => { - const token = createMockJWT({ - owner_id: "team1", - project_id: "proj1", - }); - getVercelOidcTokenMock.mockImplementationOnce(async () => "hello world"); - const expiry = await JwtExpiry.fromToken(token)?.refresh(); - expect(expiry).toBeInstanceOf(JwtExpiry); - expect(expiry?.token).toEqual("hello world"); - }); - - test("isValid returns true for tokens without expiry", () => { - // Mock token without exp field (like OIDC tokens without exp) - const tokenWithoutExp = createMockJWT({ - owner_id: "team1", - project_id: "proj1", - }); - const expiry = JwtExpiry.fromToken(tokenWithoutExp); - assert(expiry, "Expiry should not be null for valid JWT"); - expect(expiry.isValid()).toBe(false); // No exp field means malformed JWT - }); - - test("isValid returns true for unexpired tokens", () => { - const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now - const tokenValid = createMockJWT({ - owner_id: "team1", - project_id: "proj1", - exp: futureTime, - }); - const expiry = JwtExpiry.fromToken(tokenValid); - assert(expiry, "Expiry should not be null for valid JWT"); - expect(expiry.isValid()).toBe(true); - }); - - test("isValid returns false for expired tokens", () => { - const pastTime = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago - const tokenExpired = createMockJWT({ - owner_id: "team1", - project_id: "proj1", - exp: pastTime, - }); - const expiry = JwtExpiry.fromToken(tokenExpired); - assert(expiry, "Expiry should not be null for valid JWT"); - expect(expiry.isValid()).toBe(false); - }); - - test("isValid returns false for tokens expiring within buffer time", () => { - const soonTime = Math.floor(Date.now() / 1000) + 120; // 2 minutes from now - const tokenExpiringSoon = createMockJWT({ - owner_id: "team1", - project_id: "proj1", - exp: soonTime, - }); - const expiry = JwtExpiry.fromToken(tokenExpiringSoon); - assert(expiry, "Expiry should not be null for valid JWT"); - expect(expiry.isValid(5)).toBe(false); // 5 minute buffer - }); - - test("isValid returns false for malformed JWT tokens", () => { - const expiry = JwtExpiry.fromToken("header.invalid-payload.signature"); - assert(expiry, "Expiry should not be null for valid JWT"); - expect(expiry.isValid()).toBe(false); - }); - - test("getExpiryDate returns correct expiry date", () => { - const expTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now - const token = createMockJWT({ - owner_id: "team1", - project_id: "proj1", - exp: expTime, - }); - const expiry = JwtExpiry.fromToken(token); - assert(expiry, "Expiry should not be null for valid JWT"); - expect(expiry.getExpiryDate()).toEqual(new Date(expTime * 1000)); - }); - - test("getExpiryDate returns null for tokens without expiry", () => { - const token = createMockJWT({ owner_id: "team1", project_id: "proj1" }); - const expiry = JwtExpiry.fromToken(token); - assert(expiry, "Expiry should not be null for valid JWT"); - expect(expiry.getExpiryDate()).toBeNull(); - }); - - test("getExpiryDate returns null for malformed tokens", () => { - const token = "hello.world.hey"; - const expiry = JwtExpiry.fromToken(token); - assert(expiry, "Expiry should not be null for valid JWT"); - expect(expiry.getExpiryDate()).toBeNull(); - }); - - test("returns null for non-JWT style tokens", () => { - expect(JwtExpiry.fromToken("personal-access-token")).toBeNull(); - }); -}); - -// Helper function to create mock JWT tokens for testing -function createMockJWT(payload: any): string { - const header = { typ: "JWT", alg: "HS256" }; - const encodedHeader = Buffer.from(JSON.stringify(header)).toString( - "base64url", - ); - const encodedPayload = Buffer.from(JSON.stringify(payload)).toString( - "base64url", - ); - const signature = "mock-signature"; - - return `${encodedHeader}.${encodedPayload}.${signature}`; -} diff --git a/packages/vercel-sandbox/src/utils/jwt-expiry.ts b/packages/vercel-sandbox/src/utils/jwt-expiry.ts deleted file mode 100644 index c89d84c..0000000 --- a/packages/vercel-sandbox/src/utils/jwt-expiry.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { z } from "zod"; -import { decodeBase64Url } from "./decode-base64-url"; -import { schema } from "./get-credentials"; -import { getVercelOidcToken } from "@vercel/oidc"; -import ms from "ms"; - -/** Time buffer before token expiry to consider it invalid (in milliseconds) */ -const BUFFER_MS = ms("5m"); - -export class OidcRefreshError extends Error { - name = "OidcRefreshError"; -} - -/** - * Expiry implementation for JWT tokens (OIDC tokens). - * Parses the JWT once and provides fast expiry validation. - */ -export class JwtExpiry { - private expiryTime: number | null; // Unix timestamp in seconds - readonly payload?: Readonly>; - - static fromToken(token: string): JwtExpiry | null { - if (!isJwtFormat(token)) { - return null; - } else { - return new JwtExpiry(token); - } - } - - /** - * Creates a new JWT expiry checker. - * - * @param token - The JWT token to parse - */ - constructor(readonly token: string) { - try { - const tokenContents = token.split(".")[1]; - this.payload = schema.parse(decodeBase64Url(tokenContents)); - this.expiryTime = this.payload.exp || null; - } catch { - // Malformed token - treat as expired to trigger refresh - this.expiryTime = 0; - } - } - - /** - * Checks if the JWT token is valid (not expired). - * @returns true if token is valid, false if expired or expiring soon - */ - isValid(): boolean { - if (this.expiryTime === null) { - return false; // No expiry means malformed JWT - } - - const now = Math.floor(Date.now() / 1000); - const buffer = BUFFER_MS / 1000; - return now + buffer < this.expiryTime; - } - - /** - * Gets the expiry date of the JWT token. - * - * @returns Date object representing when the token expires, or null if no expiry - */ - getExpiryDate(): Date | null { - return this.expiryTime ? new Date(this.expiryTime * 1000) : null; - } - - /** - * Refreshes the JWT token by fetching a new OIDC token. - * - * @returns Promise resolving to a new JwtExpiry instance with fresh token - */ - async refresh(): Promise { - try { - const freshToken = await getVercelOidcToken(); - return new JwtExpiry(freshToken); - } catch (cause) { - throw new OidcRefreshError("Failed to refresh OIDC token", { - cause, - }); - } - } - - /** - * Refreshes the JWT token if it's expired or expiring soon. - */ - async tryRefresh(): Promise { - if (this.isValid()) { - return null; // Still valid, no need to refresh - } - - return this.refresh(); - } -} - -/** - * Checks if a token follows JWT format (has 3 parts separated by dots). - * - * @param token - The token to check - * @returns true if token appears to be a JWT, false otherwise - */ -function isJwtFormat(token: string): boolean { - return token.split(".").length === 3; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d89c4a4..e439451 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,8 +290,8 @@ importers: specifier: ^22.15.12 version: 22.15.12 '@vercel/oidc': - specifier: ^3.1.0 - version: 3.1.0 + specifier: file:../../../vercel/packages/oidc + version: file:../vercel/packages/oidc '@vercel/pty-tunnel': specifier: workspace:* version: link:../pty-tunnel @@ -335,8 +335,8 @@ importers: packages/vercel-sandbox: dependencies: '@vercel/oidc': - specifier: ^3.1.0 - version: 3.1.0 + specifier: file:../../../vercel/packages/oidc + version: file:../vercel/packages/oidc async-retry: specifier: 1.3.3 version: 1.3.3 @@ -1505,8 +1505,8 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vercel/oidc@3.1.0': - resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + '@vercel/oidc@file:../vercel/packages/oidc': + resolution: {directory: ../vercel/packages/oidc, type: directory} engines: {node: '>= 20'} '@vitest/expect@3.2.1': @@ -4797,7 +4797,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vercel/oidc@3.1.0': {} + '@vercel/oidc@file:../vercel/packages/oidc': {} '@vitest/expect@3.2.1': dependencies: