-
Notifications
You must be signed in to change notification settings - Fork 133
feat: use local HTTP server for OAuth callback - AAP-70006 #2695
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
c75d376
3df7183
0430c60
62321bf
ac28ed3
29ca18d
ab3484a
a236062
1cdd5dc
a9f5e77
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| import { AddressInfo } from "net"; | ||
|
Check warning on line 18 in src/features/lightspeed/lightSpeedOAuthProvider.ts
|
||
| import { | ||
| PromiseAdapter, | ||
| promiseFromEvent, | ||
|
|
@@ -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`); | ||
|
|
@@ -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()); | ||
| }); | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 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 When 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); | ||
|
|
||
|
|
@@ -315,7 +412,7 @@ | |
| }, | ||
| async (_, token): Promise<OAuthAccount> => { | ||
| try { | ||
| return await Promise.race<OAuthAccount>([ | ||
| const candidates: Promise<OAuthAccount>[] = [ | ||
| receivedRedirectUrl, | ||
| new Promise<OAuthAccount>((_, reject) => { | ||
| setTimeout(() => { | ||
|
|
@@ -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(); | ||
| } | ||
| }, | ||
|
|
@@ -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"); | ||
|
|
||
|
|
@@ -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")); | ||
|
|
@@ -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", | ||
|
|
@@ -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, | ||
| }, | ||
| ); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.