-
Notifications
You must be signed in to change notification settings - Fork 369
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Agent: authentication capability - enable url handler and auth redire…
…ctions (#5325) Allows agent to set up HTTP server to listen for authentication redirections when agent capabilities for authentication are enabled. TODO: - [x] authentication url handler - [x] agent capability configuration - [x] work with secret storage (added by #5348) Follow-ups: - we should close the opened browser to refocus the webview if possible. ## Test plan <!-- Required. See https://docs-legacy.sourcegraph.com/dev/background-information/testing_principles. --> Example of this feature working in Visual Studio: https://github.com/user-attachments/assets/d21c8c2f-2667-426a-9c4d-991b7645d2e5 Updated onboarding view for non VS Code editors to support login with browser for enterprise:  ## Changelog <!-- OPTIONAL; info at https://www.notion.so/sourcegraph/Writing-a-changelog-entry-dd997f411d524caabf0d8d38a24a878c -->
- Loading branch information
Showing
18 changed files
with
354 additions
and
37 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import http from 'node:http' | ||
import open from 'open' | ||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
import { URI } from 'vscode-uri' | ||
import { AgentAuthHandler } from './AgentAuthHandler' | ||
|
||
vi.mock('open') | ||
vi.mock('node:http', () => ({ | ||
default: { | ||
createServer: vi.fn().mockReturnValue({ | ||
listen: vi.fn().mockImplementation((port, uri, callback) => { | ||
callback() | ||
}), | ||
on: vi.fn(), | ||
address: vi.fn().mockReturnValue({ port: 123 }), | ||
}), | ||
}, | ||
})) | ||
|
||
describe('AgentAuthHandler', () => { | ||
let agentAuthHandler: AgentAuthHandler | ||
|
||
beforeEach(() => { | ||
agentAuthHandler = new AgentAuthHandler() | ||
agentAuthHandler.setTokenCallbackHandler(uri => console.log(`Token received: ${uri}`)) | ||
}) | ||
|
||
afterEach(() => { | ||
vi.clearAllMocks() | ||
}) | ||
|
||
describe('handleCallback', () => { | ||
it.each([ | ||
[ | ||
'valid endpointUri', | ||
'https://sourcegraph.test/user/settings/tokens/new/callback?requestFrom=CODY_JETBRAINS', | ||
'https://sourcegraph.test/user/settings/tokens/new/callback?requestFrom=CODY_JETBRAINS-123', | ||
], | ||
[ | ||
'valid endpointUri with additional params appended', | ||
'https://sourcegraph.com/user/settings/tokens/new/callback?requestFrom=VISUAL_STUDIO&tokenReceiverUrl=https%3A%2F%2Fexample.com', | ||
'https://sourcegraph.com/user/settings/tokens/new/callback?requestFrom=VISUAL_STUDIO-123&tokenReceiverUrl=https%3A%2F%2Fexample.com', | ||
], | ||
['invalid IDE', 'https://sourcegraph.com', 'https://sourcegraph.com/'], | ||
['invalid endpointUri', 'invalid-url', undefined], | ||
])('%s', (_, endpointUri: string, expectedUrl?: string) => { | ||
const uri = URI.parse(endpointUri) | ||
|
||
agentAuthHandler.handleCallback(uri) | ||
|
||
if (!expectedUrl) { | ||
expect(http.createServer).not.toHaveBeenCalled() | ||
} else { | ||
expect(http.createServer).toHaveBeenCalled() | ||
expect(open).toHaveBeenCalledWith(expect.stringContaining(expectedUrl)) | ||
} | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,161 @@ | ||
import type { IncomingMessage, Server, ServerResponse } from 'node:http' | ||
import http from 'node:http' | ||
import type { AddressInfo } from 'node:net' | ||
import { logDebug } from '@sourcegraph/cody-shared' | ||
import open from 'open' | ||
import { URI } from 'vscode-uri' | ||
|
||
type CallbackHandler = (url: URI, token?: string) => void | ||
|
||
const SIX_MINUTES = 6 * 60 * 1000 | ||
|
||
/** | ||
* Handles the authentication flow for Agent clients. | ||
* Manages the creation and lifecycle of an HTTP server to handle the token callback from the Sourcegraph login flow. | ||
* Redirects the user to the Sourcegraph instance login page and handles the token callback. | ||
*/ | ||
export class AgentAuthHandler { | ||
private port = 0 | ||
private server: Server | null = null | ||
private tokenCallbackHandlers: CallbackHandler[] = [] | ||
|
||
public setTokenCallbackHandler(handler: CallbackHandler): void { | ||
this.tokenCallbackHandlers.push(handler) | ||
} | ||
|
||
public handleCallback(url: URI): void { | ||
try { | ||
const callbackUri = getValidCallbackUri(url.toString()) | ||
if (!callbackUri) { | ||
throw new Error(url.toString() + ' is not a valid URL') | ||
} | ||
this.startServer(url.toString()) | ||
} catch (error) { | ||
logDebug('AgentAuthHandler', `Invalid callback URL: ${error}`) | ||
} | ||
} | ||
|
||
private startServer(callbackUri: string): void { | ||
if (!this.tokenCallbackHandlers?.length) { | ||
logDebug('AgentAuthHandler', 'Token callback handler is not set.') | ||
return | ||
} | ||
|
||
if (this.server && this.port) { | ||
logDebug('AgentAuthHandler', 'Server already running') | ||
this.redirectToEndpointLoginPage(callbackUri) | ||
return | ||
} | ||
|
||
// Create an HTTP server to handle the token callback | ||
const server = http.createServer((req: IncomingMessage, res: ServerResponse) => { | ||
if (req.url?.startsWith('/api/sourcegraph/token')) { | ||
const url = new URL(req.url, `http://127.0.0.1:${this.port}`) | ||
const token = url.searchParams.get('token') | ||
if (token) { | ||
for (const handler of this.tokenCallbackHandlers) { | ||
handler(URI.parse(req.url), token) | ||
} | ||
res.writeHead(200, { 'Content-Type': 'text/html' }) | ||
// Close the window once the token is received. | ||
res.end(` | ||
<html> | ||
<body> | ||
Token received. This window will close automatically. | ||
<script> | ||
window.close(); | ||
</script> | ||
</body> | ||
</html> | ||
`) | ||
this.closeServer() | ||
} else { | ||
res.writeHead(400, { 'Content-Type': 'text/plain' }) | ||
res.end('Token not found.') | ||
} | ||
} else { | ||
res.writeHead(404, { 'Content-Type': 'text/plain' }) | ||
res.end('Not found') | ||
} | ||
}) | ||
|
||
// Bind the server to the loopback interface | ||
server.listen(0, '127.0.0.1', () => { | ||
// The server is now bound to the loopback interface (127.0.0.1). | ||
// This ensures that only local processes can connect to it. | ||
this.port = (server.address() as AddressInfo).port | ||
this.server = server | ||
logDebug('AgentAuthHandler', `Server listening on port ${this.port}`) | ||
this.redirectToEndpointLoginPage(callbackUri) | ||
// Automatically close the server after 6 minutes, | ||
// as the startTokenReceiver in token-receiver.ts only listens for 5 minutes. | ||
setTimeout(() => this.closeServer(), SIX_MINUTES) | ||
}) | ||
|
||
// Handle server errors | ||
server.on('error', error => { | ||
logDebug('AgentAuthHandler', `Server error: ${error}`) | ||
}) | ||
} | ||
|
||
private closeServer(): void { | ||
if (this.server) { | ||
logDebug('AgentAuthHandler', 'Auth server closed') | ||
this.server.close() | ||
} | ||
this.server = null | ||
this.port = 0 | ||
} | ||
|
||
/** | ||
* Redirects the user to the endpoint login page with the updated callback URI. | ||
* | ||
* The callback URI is updated by finding the 'requestFrom' parameter in the query string, | ||
* removing the old parameter, and adding a new parameter with the correct port number appended. | ||
* | ||
* @param callbackUri - The original callback URI to be updated. | ||
*/ | ||
private redirectToEndpointLoginPage(callbackUri: string): void { | ||
const uri = new URL(callbackUri) | ||
const params = new URLSearchParams(decodeURIComponent(uri.search)) | ||
const requestFrom = params.get('requestFrom') | ||
if (requestFrom) { | ||
// Add the new parameter with the correct port number appended. | ||
const newRequestFrom = `${requestFrom}-${this.port}` | ||
params.set('requestFrom', newRequestFrom) | ||
const redirect = params.get('redirect') | ||
if (redirect) { | ||
params.set('redirect', redirect.replace(requestFrom, newRequestFrom)) | ||
} | ||
uri.search = params.toString() | ||
} | ||
open(uri.toString()) | ||
} | ||
|
||
public dispose(): void { | ||
this.closeServer() | ||
} | ||
} | ||
|
||
/** | ||
* Validates and normalizes a given callback URI. | ||
* | ||
* @param uri - The callback URI to validate and normalize. | ||
* @returns The validated and normalized URI, or `null` if the input URI is invalid. | ||
* @throws {Error} If the input URI is empty or starts with `file:`. | ||
*/ | ||
function getValidCallbackUri(uri: string): URI | null { | ||
if (!uri || uri.startsWith('file:')) { | ||
throw new Error('Empty URL') | ||
} | ||
try { | ||
const endpointUri = new URL(uri) | ||
if (!endpointUri.protocol.startsWith('http')) { | ||
endpointUri.protocol = 'https:' | ||
} | ||
return URI.parse(endpointUri.href) | ||
} catch (error) { | ||
logDebug('Invalid URL: ', `${error}`) | ||
return null | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { CodyIDE } from '../configuration' | ||
|
||
/** | ||
* Returns a known referral code to use based on the current VS Code environment. | ||
*/ | ||
export function getCodyAuthReferralCode(ideName: CodyIDE, uriScheme?: string): string | undefined { | ||
const referralCodes: Record<CodyIDE, string> = { | ||
[CodyIDE.JetBrains]: 'CODY_JETBRAINS', | ||
[CodyIDE.Neovim]: 'CODY_NEOVIM', | ||
[CodyIDE.Emacs]: 'CODY_EMACS', | ||
[CodyIDE.VisualStudio]: 'VISUAL_STUDIO', | ||
[CodyIDE.Eclipse]: 'ECLIPSE', | ||
[CodyIDE.VSCode]: 'CODY', | ||
[CodyIDE.Web]: 'CODY', | ||
} | ||
|
||
if (ideName === CodyIDE.VSCode) { | ||
switch (uriScheme) { | ||
case 'vscode-insiders': | ||
return 'CODY_INSIDERS' | ||
case 'vscodium': | ||
return 'CODY_VSCODIUM' | ||
case 'cursor': | ||
return 'CODY_CURSOR' | ||
} | ||
} | ||
|
||
return referralCodes[ideName] || undefined | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.