Skip to content
Open
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export default defineConfig(
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-base-to-string": "error",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"local/node-DEP0190": "error",
"no-case-declarations": "error",
"no-constant-condition": "error",
Expand Down
2 changes: 2 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,8 @@ export async function activate(context: ExtensionContext): Promise<void> {
providerManager: lightSpeedManager.providerManager,
llmProviderSettings: llmProviderSettings,
lightspeedUser: lightSpeedManager.lightspeedAuthenticatedUser,
lightSpeedAuthenticationProvider:
lightSpeedManager.lightSpeedAuthenticationProvider,
quickLinksProvider: quickLinksHome,
});
},
Expand Down
2 changes: 1 addition & 1 deletion src/features/lightspeed/ansibleContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ Generate Ansible inventory content with:
*/
private static preprocessAnsibleContent(
content: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars

_fileType: string,
): string {
try {
Expand Down
26 changes: 26 additions & 0 deletions src/features/lightspeed/commands/providerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,25 @@ export class ProviderCommands {
}
}

/**
* Remove all OAuth sessions (equivalent to "Sign Out" in VSCode accounts menu).
*/
private async signOutOAuthSessions(): Promise<void> {
const sessions =
await this.lightSpeedManager.lightSpeedAuthenticationProvider.getSessions();
for (const session of sessions) {
await this.lightSpeedManager.lightSpeedAuthenticationProvider.removeSession(
session.id,
);
}
}

/**
* Configure LLM provider through guided setup
*/
private async configureLlmProvider(): Promise<void> {
try {
const previousProvider = this.llmProviderSettings.getProvider();
const supportedProviders = providerFactory.getSupportedProviders();

// Step 1: Select provider type
Expand Down Expand Up @@ -128,6 +142,11 @@ export class ProviderCommands {
selectedProvider.provider.type,
);

// Sign out OAuth sessions if provider changed
if (selectedProvider.provider.type !== previousProvider) {
await this.signOutOAuthSessions();
}

const providerType = selectedProvider.provider.type;

// Configure required fields using generic API
Expand Down Expand Up @@ -277,11 +296,18 @@ export class ProviderCommands {
}

try {
const previousProvider = this.llmProviderSettings.getProvider();

// Switch to selected provider using LlmProviderSettings
await this.llmProviderSettings.setProvider(
selectedProvider.provider.type,
);

// Sign out OAuth sessions if provider changed
if (selectedProvider.provider.type !== previousProvider) {
await this.signOutOAuthSessions();
}

// Enable lightspeed in VS Code settings
const config = vscode.workspace.getConfiguration("ansible.lightspeed");
await config.update(
Expand Down
4 changes: 2 additions & 2 deletions src/features/lightspeed/explorerWebviewViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ export class LightspeedExplorerWebviewViewProvider implements WebviewViewProvide

public async resolveWebviewView(
webviewView: WebviewView,
// eslint-disable-next-line @typescript-eslint/no-unused-vars

_resolveContext: WebviewViewResolveContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars

_token: CancellationToken,
) {
this._view = webviewView;
Expand Down
147 changes: 128 additions & 19 deletions src/features/lightspeed/lightSpeedOAuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
} from "vscode";
import { v4 as uuid } from "uuid";
import crypto from "crypto";
import http from "http";

Check warning on line 17 in src/features/lightspeed/lightSpeedOAuthProvider.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:http` over `http`.

See more on https://sonarcloud.io/project/issues?id=ansible_vscode-ansible&issues=AZ1Dhy9gUWV3oZU_TKMT&open=AZ1Dhy9gUWV3oZU_TKMT&pullRequest=2695
import { AddressInfo } from "net";

Check warning on line 18 in src/features/lightspeed/lightSpeedOAuthProvider.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:net` over `net`.

See more on https://sonarcloud.io/project/issues?id=ansible_vscode-ansible&issues=AZ1Dhy9gUWV3oZU_TKMU&open=AZ1Dhy9gUWV3oZU_TKMU&pullRequest=2695
import {
PromiseAdapter,
promiseFromEvent,
Expand Down Expand Up @@ -167,13 +169,15 @@

/**
* Create a new auth session
* @param scopes - Scopes
* @param _scopes - Scopes
* @returns
*/
public async createSession(scopes: string[]): Promise<LightspeedAuthSession> {
public async createSession(
_scopes: string[],
): Promise<LightspeedAuthSession> {
try {
lightSpeedManager.currentModelValue = undefined;
const account = await this.login(scopes);
const account = await this.login();

if (!account) {
throw new Error(`Ansible Lightspeed login failure`);
Expand Down Expand Up @@ -276,34 +280,127 @@
}
}

/**
* Start a local HTTP server to receive the OAuth callback.
* Returns a promise that resolves with the authorization code and
* a cleanup function to shut down the server.
*/
private async startLocalCallbackServer(): Promise<{
redirectUri: string;
codePromise: Promise<string>;
close: () => void;
}> {
let resolveCode: (code: string) => void;
let rejectCode: (err: Error) => void;
const codePromise = new Promise<string>((resolve, reject) => {
resolveCode = resolve;
rejectCode = reject;
});

const server = http.createServer((req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
if (url.pathname !== "/callback") {
res.writeHead(404);
res.end("Not found");
return;
}

const code = url.searchParams.get("code");
const error = url.searchParams.get("error");

res.writeHead(200, { "Content-Type": "text/html" });
if (code) {
res.end(
"<html><body><h1>Authentication successful</h1><p>You can close this tab and return to your editor.</p></body></html>",
);
resolveCode(code);
} else {
res.end(
"<html><body><h1>Authentication failed</h1><p>Something went wrong. Return to your editor for details.</p></body></html>",
);
rejectCode(
new Error(error || "No authorization code received from the server"),
);
}
});

await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});

const port = (server.address() as AddressInfo).port;
const redirectUri = `http://127.0.0.1:${port}/callback`;

this._logger.debug(
`[ansible-lightspeed-oauth] Local callback server listening on ${redirectUri}`,
);

const close = () => {
server.close();
};

return { redirectUri, codePromise, close };
}

private static readonly LOOPBACK_CALLBACK_HOSTS = [
"https://c.ai.ansible.redhat.com",
"https://stage.ai.ansible.redhat.com",
];

/* Log in to the Ansible Lightspeed auth service */
private async login(scopes: string[] = []) {
private async login() {
this._logger.debug("[ansible-lightspeed-oauth] Logging in...");

await this.setExternalRedirectUri();

const searchParams = new URLSearchParams([
["response_type", "code"],
["code_challenge", getCodeChallenge()],
["code_challenge_method", "S256"],
["client_id", LIGHTSPEED_CLIENT_ID],
["redirect_uri", this._externalRedirectUri],
]);

const base_uri = await getBaseUri(this.settingsManager);
if (!base_uri) {
throw new Error(
"Please enter the Ansible Lightspeed URL under the Ansible Lightspeed settings!",
);
}

const useLoopback =
LightSpeedAuthenticationProvider.LOOPBACK_CALLBACK_HOSTS.includes(
base_uri,
);

let localServer:
| Awaited<ReturnType<typeof this.startLocalCallbackServer>>
| undefined;
let redirectUri: string;

if (useLoopback) {
localServer = await this.startLocalCallbackServer();
redirectUri = localServer.redirectUri;
this._logger.debug(
`[ansible-lightspeed-oauth] Using local callback redirect URI: ${redirectUri}`,
);
} else {
redirectUri = this._externalRedirectUri;
this._logger.debug(
`[ansible-lightspeed-oauth] Using external redirect URI: ${redirectUri}`,
);
}

const searchParams = new URLSearchParams([
["response_type", "code"],
["code_challenge", getCodeChallenge()],
["code_challenge_method", "S256"],
["client_id", LIGHTSPEED_CLIENT_ID],
["redirect_uri", redirectUri],
Comment on lines +385 to +413
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In OAuth 2.0 / PKCE for native apps, does a single authorization request redirect only to the one redirect_uri sent in that request, or can it effectively fall back to a second redirect URI if the first callback path fails?

💡 Result:

In OAuth 2.0 / PKCE for native apps, a single authorization request redirects only to the one redirect_uri sent in that request. The authorization server does not fallback to a second redirect URI if the first callback path fails. The server strictly validates the redirect_uri parameter against registered values for the client using exact string matching (per RFC 6749 Section 3.1.2.3 and RFC 8252 Section 8.4) and redirects the user agent exclusively to that URI if valid. If invalid or mismatched, it returns an error without redirecting. There is no provision in the protocol for automatic fallback to alternative URIs; client apps must ensure the specified redirect_uri is reachable.

Citations:


This does not provide a real vscode:// fallback.

When useLoopback is true, the authorize request at lines 385–390 specifies the loopback redirect_uri. Per OAuth 2.0 (RFC 6749 / RFC 8252), the authorization server redirects exclusively to that one registered URI—there is no automatic fallback to alternative callback handlers. If the loopback callback path fails after the browser step completes, the URI-handler branch at lines 396–400 cannot intercept it. To provide actual recovery, retry the authorization request with this._externalRedirectUri after loopback fails, or have the callback page explicitly hand off to the custom URI handler.

Also applies to: 396–400, 415–439

]);

const query = searchParams.toString();
const uri = Uri.parse(base_uri).with({ path: "/o/authorize/", query });

const {
promise: receivedRedirectUrl,
cancel: cancelWaitingForRedirectUrl,
} = promiseFromEvent(this._uriHandler.event, this.handleUriForCode(scopes));
} = promiseFromEvent(
this._uriHandler.event,
this.handleUriForCode(redirectUri),
);

await env.openExternal(uri);

Expand All @@ -315,7 +412,7 @@
},
async (_, token): Promise<OAuthAccount> => {
try {
return await Promise.race<OAuthAccount>([
const candidates: Promise<OAuthAccount>[] = [
receivedRedirectUrl,
new Promise<OAuthAccount>((_, reject) => {
setTimeout(() => {
Expand All @@ -329,8 +426,19 @@
promiseFromEvent(token.onCancellationRequested, (_, __, reject) => {
reject("User Cancelled");
}).promise as Promise<OAuthAccount>,
]);
];

if (localServer) {
candidates.push(
localServer.codePromise.then((code) =>
this.requestOAuthAccountFromCode(code, redirectUri),
) as Promise<OAuthAccount>,
);
}

return await Promise.race<OAuthAccount>(candidates);
} finally {
localServer?.close();
cancelWaitingForRedirectUrl.fire();
}
},
Expand All @@ -341,9 +449,9 @@

/* Handle the redirect to VS Code (after sign in from the Ansible Lightspeed auth service) */
private handleUriForCode: (
scopes: readonly string[],
redirectUri: string,
) => PromiseAdapter<Uri, OAuthAccount> =
() => async (uri, resolve, reject) => {
(redirectUri) => async (uri, resolve, reject) => {
const query = new URLSearchParams(uri.query);
const code = query.get("code");

Expand All @@ -356,7 +464,7 @@
return;
}

const account = await this.requestOAuthAccountFromCode(code);
const account = await this.requestOAuthAccountFromCode(code, redirectUri);

if (!account) {
reject(new Error("Unable to form account"));
Expand All @@ -369,6 +477,7 @@
/* Request access token from server using code */
private async requestOAuthAccountFromCode(
code: string,
redirectUri: string,
): Promise<OAuthAccount | undefined> {
const headers = {
"Cache-Control": "no-cache",
Expand All @@ -387,7 +496,7 @@
{
method: "POST",
signal: AbortSignal.timeout(ANSIBLE_LIGHTSPEED_API_TIMEOUT),
body: `client_id=${encodeURIComponent(LIGHTSPEED_CLIENT_ID)}&code_verifier=${encodeURIComponent(getCodeVerifier())}&grant_type=authorization_code&code=${code}&redirect_uri=${encodeURIComponent(this._externalRedirectUri)}`,
body: `client_id=${encodeURIComponent(LIGHTSPEED_CLIENT_ID)}&code_verifier=${encodeURIComponent(getCodeVerifier())}&grant_type=authorization_code&code=${code}&redirect_uri=${encodeURIComponent(redirectUri)}`,
headers,
},
);
Expand Down
1 change: 0 additions & 1 deletion src/features/lightspeed/providers/rhcustom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,6 @@ export class RHCustomProvider extends BaseLLMProvider<RHCustomConfig> {
}

async completionRequest(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_params: CompletionRequestParams,
): Promise<CompletionResponseParams> {
// Inline suggestions are out of scope for the Red Hat AI provider currently
Expand Down
Loading
Loading