Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
81 changes: 81 additions & 0 deletions packages/cloud/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ vi.mock('node:fs/promises', () => ({
}));

import { ensureAuthenticated, readStoredAuth, refreshStoredAuth } from './auth.js';
import { AUTH_FILE_PATH, LEGACY_AUTH_FILE_PATH } from './types.js';
import type { StoredAuth } from './types.js';

const FILE_AUTH: StoredAuth = {
Expand Down Expand Up @@ -41,6 +42,7 @@ function createEnvAuth(overrides: Partial<StoredAuth> = {}): NodeJS.ProcessEnv {
}

beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
vi.unstubAllGlobals();

Expand Down Expand Up @@ -92,6 +94,37 @@ describe('readStoredAuth', () => {
expect(fsMocks.readFile).toHaveBeenCalledOnce();
});

it('maps the new cloud.json file shape to runtime auth', async () => {
fsMocks.readFile.mockResolvedValue(
JSON.stringify({
apiUrl: 'https://cloud.example',
cloudToken: 'cloud-token',
expiresAt: '2026-04-13T12:00:00.000Z',
userId: 'user_123',
workspaces: [{ id: 'workspace_123', name: 'Support' }],
})
);

await expect(readStoredAuth({})).resolves.toEqual({
apiUrl: 'https://cloud.example',
accessToken: 'cloud-token',
refreshToken: '',
accessTokenExpiresAt: '2026-04-13T12:00:00.000Z',
userId: 'user_123',
workspaces: [{ id: 'workspace_123', name: 'Support' }],
});
});

it('falls back to the legacy auth file path', async () => {
fsMocks.readFile
.mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
.mockResolvedValueOnce(JSON.stringify(FILE_AUTH));

await expect(readStoredAuth({})).resolves.toEqual(FILE_AUTH);
expect(fsMocks.readFile).toHaveBeenNthCalledWith(1, AUTH_FILE_PATH, 'utf8');
expect(fsMocks.readFile).toHaveBeenNthCalledWith(2, LEGACY_AUTH_FILE_PATH, 'utf8');
});

it('prefers env auth over file auth when both are available', async () => {
const env = createEnvAuth();
fsMocks.readFile.mockResolvedValue(JSON.stringify(FILE_AUTH));
Expand Down Expand Up @@ -180,6 +213,54 @@ describe('ensureAuthenticated', () => {
const calledUrl = String(fetchSpy.mock.calls[0][0]);
expect(calledUrl).toContain('origin.example');
});

it('logs in with a one-time code poll and writes the new cloud config path', async () => {
fsMocks.readFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.stubEnv('AGENT_RELAY_NO_BROWSER', '1');

const fetchSpy = vi.fn(async (input: string | URL) => {
const url = new URL(String(input));
expect(url.pathname).toBe('/api/v1/auth/cli-login/poll');
expect(url.searchParams.get('code')).toMatch(/^c_[A-Za-z0-9_-]+$/);

return new Response(
JSON.stringify({
cloudToken: 'cloud-token-test',
userId: 'user_123',
workspaces: [{ id: 'workspace_123', name: 'Support' }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
);
});
vi.stubGlobal('fetch', fetchSpy);

const result = await ensureAuthenticated('https://cloud.test', { force: true });

expect(result).toEqual({
apiUrl: 'https://cloud.test',
accessToken: 'cloud-token-test',
refreshToken: '',
accessTokenExpiresAt: expect.any(String),
userId: 'user_123',
workspaces: [{ id: 'workspace_123', name: 'Support' }],
});
expect(consoleLog).toHaveBeenCalledWith(expect.stringMatching(/^Opening browser for cloud login: /));
expect(fsMocks.mkdir).toHaveBeenCalledWith(expect.stringContaining('.config/agent-relay'), {
recursive: true,
mode: 0o700,
});
expect(fsMocks.writeFile).toHaveBeenCalledWith(
AUTH_FILE_PATH,
expect.stringContaining('"cloudToken": "cloud-token-test"'),
{
encoding: 'utf8',
mode: 0o600,
}
);

consoleLog.mockRestore();
});
});

describe('refreshStoredAuth', () => {
Expand Down
202 changes: 192 additions & 10 deletions packages/cloud/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';

import { buildApiUrl } from './api-client.js';
import { AUTH_FILE_PATH, REFRESH_WINDOW_MS, type StoredAuth } from './types.js';
import {
AUTH_FILE_PATH,
LEGACY_AUTH_FILE_PATH,
REFRESH_WINDOW_MS,
type CloudAuthFile,
type CliLoginPollResponse,
type CloudLoginWorkspace,
type StoredAuth,
} from './types.js';

const envBackedAuth = new WeakSet<StoredAuth>();

Expand Down Expand Up @@ -69,34 +78,80 @@
);
}

function isValidCloudAuthFile(value: unknown): value is CloudAuthFile {
if (!value || typeof value !== 'object') {
return false;
}

const auth = value as Partial<CloudAuthFile>;
return (
typeof auth.cloudToken === 'string' &&
typeof auth.expiresAt === 'string' &&
typeof auth.apiUrl === 'string'
);
}

function storedAuthFromDisk(value: unknown): StoredAuth | null {
if (isValidCloudAuthFile(value)) {
return {
apiUrl: value.apiUrl,
accessToken: value.cloudToken,
refreshToken: '',
accessTokenExpiresAt: value.expiresAt,
userId: value.userId,
workspaces: readWorkspaces(value.workspaces),
};
}

return isValidStoredAuth(value) ? value : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

function cloudAuthFileFromStoredAuth(auth: StoredAuth): CloudAuthFile {
return {
apiUrl: auth.apiUrl,
cloudToken: auth.accessToken,
userId: auth.userId,
workspaces: auth.workspaces,
expiresAt: auth.accessTokenExpiresAt,
};
}

export async function readStoredAuth(env: NodeJS.ProcessEnv = process.env): Promise<StoredAuth | null> {
const envAuth = readEnvAuth(env);
if (envAuth) {
return envAuth;
}

try {
const file = await fs.readFile(AUTH_FILE_PATH, 'utf8');
const parsed = JSON.parse(file) as unknown;
return isValidStoredAuth(parsed) ? parsed : null;
} catch {
return null;
for (const authPath of [AUTH_FILE_PATH, LEGACY_AUTH_FILE_PATH]) {
try {
const file = await fs.readFile(authPath, 'utf8');
const parsed = JSON.parse(file) as unknown;
const auth = storedAuthFromDisk(parsed);
if (auth) {
return auth;
}
} catch {
// Try the next path. The legacy path keeps older installs readable.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}

return null;
}

export async function writeStoredAuth(auth: StoredAuth): Promise<void> {
await fs.mkdir(path.dirname(AUTH_FILE_PATH), {
recursive: true,
mode: 0o700,
});
await fs.writeFile(AUTH_FILE_PATH, `${JSON.stringify(auth, null, 2)}\n`, {
await fs.writeFile(AUTH_FILE_PATH, `${JSON.stringify(cloudAuthFileFromStoredAuth(auth), null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}

export async function clearStoredAuth(): Promise<void> {
await fs.rm(AUTH_FILE_PATH, { force: true });
await fs.rm(LEGACY_AUTH_FILE_PATH, { force: true });
}

function shouldRefresh(accessTokenExpiresAt: string): boolean {
Expand All @@ -109,6 +164,10 @@
}

function openBrowser(url: string) {
if (process.env.AGENT_RELAY_NO_BROWSER === '1') {
return null;
}

const platform = os.platform();

if (platform === 'darwin') {
Expand All @@ -122,6 +181,104 @@
return spawn('xdg-open', [url], { stdio: 'ignore', detached: true });
}

function generateCliLoginCode(): string {
return `c_${randomBytes(24).toString('base64url')}`;
}

function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}

function readWorkspaces(value: unknown): CloudLoginWorkspace[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}

return value.filter((entry): entry is CloudLoginWorkspace => {
return entry !== null && typeof entry === 'object' && typeof (entry as { id?: unknown }).id === 'string';
});
}

function isPendingCliLoginResponse(response: Response, payload: CliLoginPollResponse | null): boolean {
return response.status === 202 || payload?.status === 'pending' || payload?.status === 'unclaimed';
}

function resolvePollError(response: Response, payload: CliLoginPollResponse | null): string {
return (
readString(payload?.error) ??
readString(payload?.message) ??
`Cloud login poll failed with HTTP ${response.status}`
);
}

function storedAuthFromPollPayload(apiUrl: string, payload: CliLoginPollResponse): StoredAuth | null {
const tokenFromObject =
payload.token && typeof payload.token === 'object' ? readString(payload.token.value) : undefined;
const cloudToken =
readString(payload.cloudToken) ?? readString(payload.accessToken) ?? readString(payload.token) ?? tokenFromObject;

if (!cloudToken) {
return null;
}

const tokenExpiresAt =
readString(payload.accessTokenExpiresAt) ??
readString(payload.expiresAt) ??
(payload.token && typeof payload.token === 'object' ? readString(payload.token.expiresAt) : undefined) ??
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();

return {
apiUrl,
accessToken: cloudToken,
refreshToken: '',
accessTokenExpiresAt: tokenExpiresAt,
userId: readString(payload.userId),
workspaces: readWorkspaces(payload.workspaces),
};
}

async function pollCliLoginCode(
apiUrl: string,
code: string,
options: {
timeoutMs?: number;
pollIntervalMs?: number;
} = {}
): Promise<StoredAuth> {
const timeoutMs = options.timeoutMs ?? 5 * 60_000;
const pollIntervalMs = options.pollIntervalMs ?? 1_000;
const deadline = Date.now() + timeoutMs;

while (Date.now() < deadline) {
const pollUrl = buildApiUrl(apiUrl, '/api/v1/auth/cli-login/poll');
pollUrl.searchParams.set('code', code);

const response = await fetch(pollUrl, {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
method: 'GET',
headers: { accept: 'application/json' },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const payload = (await response.json().catch(() => null)) as CliLoginPollResponse | null;

if (isPendingCliLoginResponse(response, payload)) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
continue;
}

if (!response.ok) {
throw new Error(resolvePollError(response, payload));
}

const auth = payload ? storedAuthFromPollPayload(apiUrl, payload) : null;
if (!auth) {
Comment on lines +349 to +353
throw new Error('Cloud login poll response was missing cloudToken');
}

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
return auth;
}

throw new Error('Timed out waiting for browser login');
}

function redirectToHostedCliAuthPage(
response: http.ServerResponse<http.IncomingMessage>,
apiUrl: string,
Expand All @@ -142,6 +299,24 @@
}

async function beginBrowserLogin(apiUrl: string): Promise<StoredAuth> {
const code = generateCliLoginCode();
const loginUrl = buildApiUrl(apiUrl, '/cli-login');
loginUrl.searchParams.set('code', code);

console.log(`Opening browser for cloud login: ${loginUrl.toString()}`);
console.log('If the browser does not open, paste this URL into your browser.');

try {
const child = openBrowser(loginUrl.toString());
child?.unref();
} catch {
// Browser open failure is non-fatal; user still has the URL.
}

return pollCliLoginCode(apiUrl, code);
}

async function beginCallbackBrowserLogin(apiUrl: string): Promise<StoredAuth> {
const state = crypto.randomUUID();

return new Promise<StoredAuth>((resolve, reject) => {
Expand Down Expand Up @@ -171,11 +346,11 @@
reject(new Error('Invalid state parameter in CLI login callback'));
}
return;
}

const error = requestUrl.searchParams.get('error');
if (error) {
redirectToHostedCliAuthPage(response, apiUrl, {

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
status: 'error',
detail: error,
});
Expand Down Expand Up @@ -243,7 +418,7 @@

try {
const child = openBrowser(loginUrl.toString());
child.unref();
child?.unref();
} catch {
// Browser open failure is non-fatal; user still has the URL.
}
Expand All @@ -267,6 +442,10 @@
}

export async function refreshStoredAuth(auth: StoredAuth): Promise<StoredAuth> {
if (!auth.refreshToken) {
throw new Error('Stored cloud login has expired');
}

const response = await fetch(buildApiUrl(auth.apiUrl, '/api/v1/auth/token/refresh'), {
method: 'POST',
headers: {
Expand Down Expand Up @@ -301,7 +480,10 @@
}

async function loginWithBrowser(apiUrl: string): Promise<StoredAuth> {
const auth = await beginBrowserLogin(apiUrl);
const auth =
process.env.AGENT_RELAY_CLI_LOGIN_FLOW === 'callback'
? await beginCallbackBrowserLogin(apiUrl)
: await beginBrowserLogin(apiUrl);
await writeStoredAuth(auth);
console.log(`Logged in to ${auth.apiUrl}`);
return auth;
Expand Down
Loading
Loading