Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm working on it locally, this is a local install path for local testing. Once the @vercel/oidc PR ships I'll update it.

"@vercel/pty-tunnel": "workspace:*",
"@vercel/pty-tunnel-server": "workspace:*",
"@vercel/sandbox": "workspace:*",
Expand Down
96 changes: 31 additions & 65 deletions packages/sandbox/src/args/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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();
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need an access token? Or could we also get an OIDC token for Sandbox?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handling the access token is good idea. But then it should actually be called "access token". And we should also allow it be specified via an env var, e.g. VERCEL_TOKEN. But IMHO it shouldn't be API'd to CLI specifically.

} 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<ReturnType<typeof getAuth>>) {
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);
Expand Down
174 changes: 106 additions & 68 deletions packages/sandbox/test/args/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@vercel/oidc")>(
"@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<typeof vi.fn>;

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");

Expand All @@ -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");
});
});
});
2 changes: 1 addition & 1 deletion packages/vercel-sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading