Skip to content
Merged
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
98 changes: 5 additions & 93 deletions src/cli/commands/_common/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,9 @@ import { warn, print, pressEnterKeyPrompt, isMockActive } from '../../../ui';
import { blue } from '../../../ui/colors';

const HTTP_STATUS_OK = 200;
const HTTP_STATUS_METHOD_NOT_ALLOWED = 405;
const HTTP_STATUS_PAYLOAD_TOO_LARGE = 413;
const MAX_POST_BODY_BYTES = 4096;
const SUCCESS_HTML_TITLE = 'Sonar CLI Authentication';
const SUCCESS_HTML_MESSAGE = 'Authentication Successful';
const SUCCESS_HTML_DESCRIPTION = 'You can close this window and return to the terminal.';

/**
* Get token from keychain
Expand Down Expand Up @@ -91,27 +89,6 @@ export function extractTokenFromPostBody(body: string): string | undefined {
}
}

/**
* Extract token from GET query parameters
*/
export function extractTokenFromQuery(
host: string | undefined,
url: string | undefined,
): string | undefined {
if (!host || !url) return undefined;
try {
const fullUrl = new URL(`http://${host}${url}`);
const token = fullUrl.searchParams.get('token');
// Token must be a non-empty string
if (token && token.length > 0) {
return token;
}
return undefined;
} catch {
return undefined;
}
}

/**
* Build authentication URL from server URL and port
*/
Expand All @@ -124,57 +101,6 @@ export function buildAuthURL(serverURL: string, port: number): string {
return `${cleanServerURL}/sonarlint/auth?ideName=sonarqube-cli&port=${port}`;
}

/**
* Get success HTML page
*/
export function getSuccessHTML(): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<title>${SUCCESS_HTML_TITLE}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #f5f5f5;
}
.container {
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}
.success {
color: #52c41a;
font-size: 48px;
margin-bottom: 20px;
}
h1 {
color: #333;
margin-bottom: 10px;
}
p {
color: #666;
}
</style>
</head>
<body>
<div class="container">
<div class="success">✓</div>
<h1>${SUCCESS_HTML_MESSAGE}</h1>
<p>${SUCCESS_HTML_DESCRIPTION}</p>
</div>
</body>
</html>
`;
}

/**
* Open browser, with fallback message if it fails.
* Skipped when CI=true — token must be delivered directly to the loopback server.
Expand All @@ -199,8 +125,8 @@ export function sendSuccessResponse(
extractedToken?: string,
onToken?: (token: string) => void,
): void {
res.writeHead(HTTP_STATUS_OK, { 'Content-Type': 'text/html' });
res.end(getSuccessHTML());
res.writeHead(HTTP_STATUS_OK, { 'Content-Type': 'text/plain' });
res.end('OK');
if (extractedToken && onToken) {
onToken(extractedToken);
}
Expand Down Expand Up @@ -236,30 +162,16 @@ export function handlePostRequest(
});
}

/**
* Handle GET request - extract token from query parameters
*/
export function handleGetRequest(
req: IncomingMessage,
res: ServerResponse,
onToken: (token: string) => void,
): void {
const extractedToken = extractTokenFromQuery(req.headers.host, req.url);
sendSuccessResponse(res, extractedToken ?? undefined, onToken);
}

/**
* Create request handler for loopback server
*/
export function createRequestHandler(onToken: (token: string) => void) {
return (req: IncomingMessage, res: ServerResponse) => {
if (req.method === 'POST') {
handlePostRequest(req, res, onToken);
} else if (req.method === 'GET') {
handleGetRequest(req, res, onToken);
Comment thread
damien-urruty-sonarsource marked this conversation as resolved.
} else {
res.writeHead(HTTP_STATUS_OK);
res.end('OK');
res.writeHead(HTTP_STATUS_METHOD_NOT_ALLOWED);
res.end('Method Not Allowed');
}
};
}
Expand Down
33 changes: 22 additions & 11 deletions tests/integration/harness/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export async function runCli(
const spawnEnv = { ...env, SONARQUBE_CLI_DISABLE_SENTRY: '1' };
if (coverageMode) {
mkdirSync(COVERAGE_RAW_DIR, { recursive: true });
const unique = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const unique = `${Date.now()}-${crypto.randomUUID()}`;
spawnEnv.COVERAGE_OUTPUT_FILE = join(COVERAGE_RAW_DIR, `coverage-${unique}.json`);
}

Expand All @@ -98,7 +98,7 @@ export async function runCli(
// Write each chunk with a delay so readline in the CLI process finishes
// handling one prompt before the next chunk arrives for the next prompt.
await (async () => {
for (const chunk of options.stdinChunks!) {
for (const chunk of options.stdinChunks) {
await new Promise((r) => setTimeout(r, STDIN_CHUNK_DELAY_MS));
sink.write(encoder.encode(chunk));
}
Expand Down Expand Up @@ -136,9 +136,27 @@ export async function runCli(
};
}

/**
* Extracts the loopback port from accumulated stdout and POSTs the token to it.
* Returns true if the token was delivered, false if the port was not found yet.
*/
function tryDeliverToken(accumulated: string, token: string): boolean {
const match = /[?&]port=(\d+)/.exec(accumulated);
if (!match) return false;
const port = match[1];
fetch(`http://127.0.0.1:${port}/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
}).catch(() => {
/* loopback server may close before response completes */
});
return true;
}

/**
* Reads stdout incrementally. When the loopback auth port appears in the output
* (pattern: `port=NNNNN`), delivers the token via GET to the loopback server.
* (pattern: `port=NNNNN`), delivers the token via POST to the loopback server.
* Returns the full accumulated stdout once the stream ends.
*/
async function streamStdoutAndDeliverToken(
Expand All @@ -158,14 +176,7 @@ async function streamStdoutAndDeliverToken(
accumulated += decoder.decode(value, { stream: true });

if (!tokenDelivered) {
const match = accumulated.match(/[?&]port=(\d+)/);
if (match) {
tokenDelivered = true;
const port = match[1];
fetch(`http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`).catch(() => {
/* loopback server may close before response completes */
});
}
tokenDelivered = tryDeliverToken(accumulated, token);
}
}
} finally {
Expand Down
73 changes: 71 additions & 2 deletions tests/integration/specs/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,30 @@ describe('auth login', () => {
},
{ timeout: 15000 },
);

it(
'exits with code 1 when organization is not found on SonarCloud',
async () => {
const server = await harness.newFakeServer().withAuthToken('my-token').start();

const result = await harness.run('auth login --with-token my-token --org nonexistent-org', {
extraEnv: {
SONARQUBE_CLI_SONARCLOUD_URL: server.baseUrl(),
SONARQUBE_CLI_SONARCLOUD_API_URL: server.baseUrl(),
},
});

expect(result.exitCode).toBe(1);
expect(result.stdout + result.stderr).toContain(
'Organization "nonexistent-org" not found or not accessible',
);
},
{ timeout: 15000 },
);
});

const LARGE_ORG_TOTAL = 200;

describe('auth login — organization selection', () => {
let harness: TestHarness;

Expand Down Expand Up @@ -206,7 +228,7 @@ describe('auth login — organization selection', () => {
.withOrganizations(
Array.from({ length: 10 }, (_, i) => ({ key: `org-${i}`, name: `Org ${i}` })),
)
.withOrganizationTotal(200)
.withOrganizationTotal(LARGE_ORG_TOTAL)
.start();

const result = await harness.run(`auth login --server ${server.baseUrl()}`, {
Expand All @@ -220,7 +242,7 @@ describe('auth login — organization selection', () => {

expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(
'Showing first 10 of 200 organizations. Use manual entry to select a different organization.',
`Showing first 10 of ${LARGE_ORG_TOTAL} organizations. Use manual entry to select a different organization.`,
);
expect(result.stdout).toContain(`Authentication successful for: ${server.baseUrl()} (org-2)`);
});
Expand Down Expand Up @@ -250,6 +272,31 @@ describe('auth login — organization selection', () => {
);
expect(result.stdout).not.toContain('Waiting for authorization...');
});

it(
'uses organization from sonar-project.properties when --org is not specified',
async () => {
const server = await harness
.newFakeServer()
.withAuthToken('my-token')
.withOrganizations([{ key: 'my-org', name: 'My Org' }])
.start();

harness.cwd.writeFile('sonar-project.properties', 'sonar.organization=my-org\n');

const result = await harness.run('auth login', {
extraEnv: {
SONARQUBE_CLI_SONARCLOUD_URL: server.baseUrl(),
SONARQUBE_CLI_SONARCLOUD_API_URL: server.baseUrl(),
},
browserToken: 'my-token',
});

expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('my-org');
},
{ timeout: 15000 },
);
});

describe('auth logout', () => {
Expand Down Expand Up @@ -316,6 +363,28 @@ describe('auth logout', () => {
},
{ timeout: 15000 },
);

it(
'does not remove a second org token when logging out from the active org',
async () => {
const server = await harness.newFakeServer().withAuthToken('token-org1').start();

harness
.state()
.withActiveConnection(server.baseUrl(), 'cloud', 'org1')
.withKeychainToken(server.baseUrl(), 'token-org1', 'org1')
.withKeychainToken(server.baseUrl(), 'token-org2', 'org2');

const result = await harness.run('auth logout');

expect(result.exitCode).toBe(0);

const keychain = harness.keychainJsonFile.asJson() as { tokens: Record<string, string> };
expect(Object.values(keychain.tokens)).not.toContain('token-org1');
expect(Object.values(keychain.tokens)).toContain('token-org2');
},
{ timeout: 15000 },
);
});

describe('auth purge', () => {
Expand Down
Loading
Loading