From d1cc940c4518f4b46419fb6b6c7e65c865631b24 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 21 Oct 2025 16:43:21 +0300 Subject: [PATCH 01/11] Add simple OAuth command --- package.json | 5 + src/core/secretsManager.ts | 26 ++ src/extension.ts | 14 +- src/oauth.ts | 492 +++++++++++++++++++++++++++++++++++++ 4 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 src/oauth.ts diff --git a/package.json b/package.json index 49844183..f6e96686 100644 --- a/package.json +++ b/package.json @@ -255,6 +255,11 @@ "title": "Search", "category": "Coder", "icon": "$(search)" + }, + { + "command": "coder.oauth.testAuth", + "title": "Test OAuth Auth", + "category": "Coder" } ], "menus": { diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 94827b15..94982040 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -4,6 +4,8 @@ const SESSION_TOKEN_KEY = "sessionToken"; const LOGIN_STATE_KEY = "loginState"; +const OAUTH_CLIENT_REGISTRATION_KEY = "oauthClientRegistration"; + export enum AuthAction { LOGIN, LOGOUT, @@ -70,4 +72,28 @@ export class SecretsManager { } }); } + + /** + * Store OAuth client registration data. + */ + public async setOAuthClientRegistration( + registration: string | undefined, + ): Promise { + if (registration) { + await this.secrets.store(OAUTH_CLIENT_REGISTRATION_KEY, registration); + } else { + await this.secrets.delete(OAUTH_CLIENT_REGISTRATION_KEY); + } + } + + /** + * Get OAuth client registration data. + */ + public async getOAuthClientRegistration(): Promise { + try { + return await this.secrets.get(OAUTH_CLIENT_REGISTRATION_KEY); + } catch { + return undefined; + } + } } diff --git a/src/extension.ts b/src/extension.ts index aba94cfe..effa1ed0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,6 +12,7 @@ import { Commands } from "./commands"; import { ServiceContainer } from "./core/container"; import { AuthAction } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; +import { activateCoderOAuth, CALLBACK_PATH } from "./oauth"; import { Remote } from "./remote/remote"; import { toSafeHost } from "./util"; import { @@ -121,11 +122,22 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.subscriptions, ); + const oauthHelper = activateCoderOAuth(client, secretsManager, output, ctx); + // Handle vscode:// URIs. const uriHandler = vscode.window.registerUriHandler({ handleUri: async (uri) => { - const cliManager = serviceContainer.getCliManager(); const params = new URLSearchParams(uri.query); + + if (uri.path === CALLBACK_PATH) { + const code = params.get("code"); + const state = params.get("state"); + const error = params.get("error"); + oauthHelper.handleCallback(code, state, error); + return; + } + + const cliManager = serviceContainer.getCliManager(); if (uri.path === "/open") { const owner = params.get("owner"); const workspace = params.get("workspace"); diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 00000000..4575451d --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,492 @@ +import { createHash, randomBytes } from "node:crypto"; +import * as vscode from "vscode"; + +import { type CoderApi } from "./api/coderApi"; +import { type SecretsManager } from "./core/secretsManager"; + +import type { Logger } from "./logging/logger"; + +export const CALLBACK_PATH = "/oauth/callback"; + +interface ClientRegistrationRequest { + redirect_uris: string[]; + application_type: "native" | "web"; + grant_types: string[]; + response_types: string[]; + client_name: string; + token_endpoint_auth_method: + | "none" + | "client_secret_post" + | "client_secret_basic"; +} + +interface ClientRegistrationResponse { + client_id: string; + client_secret?: string; + client_id_issued_at?: number; + client_secret_expires_at?: number; + redirect_uris: string[]; + grant_types: string[]; +} + +interface OAuthServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint?: string; + response_types_supported?: string[]; + grant_types_supported?: string[]; + code_challenge_methods_supported?: string[]; +} + +interface TokenResponse { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; +} + +/** + * Generate PKCE verifier and challenge (RFC 7636) + */ +function generatePKCE(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +/** + * OAuth helper for Coder authentication + */ +class CoderOAuthHelper { + private _clientId: string | undefined; + private _clientRegistration: ClientRegistrationResponse | undefined; + private _cachedMetadata: OAuthServerMetadata | undefined; + private _pendingAuthResolve: + | ((value: { code: string; verifier: string }) => void) + | undefined; + private _pendingAuthReject: ((reason: Error) => void) | undefined; + private _expectedState: string | undefined; + private _pendingVerifier: string | undefined; + + private readonly extensionId: string; + + constructor( + private readonly client: CoderApi, + private readonly secretsManager: SecretsManager, + private readonly logger: Logger, + context: vscode.ExtensionContext, + ) { + this.loadClientRegistration(); + this.extensionId = context.extension.id; + } + + /** + * Discover OAuth server endpoints using RFC 8414 + * Caches result in memory for the session + * Throws error if server returns 404 (OAuth not supported) + */ + private async discoverOAuthEndpoints(): Promise { + if (this._cachedMetadata) { + return this._cachedMetadata; + } + + this.logger.info("Discovering OAuth endpoints..."); + + const response = await this.client + .getAxiosInstance() + .request({ + url: "/.well-known/oauth-authorization-server", + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + const metadata = response.data; + + // Validate required fields + if ( + !metadata.authorization_endpoint || + !metadata.token_endpoint || + !metadata.issuer + ) { + throw new Error( + "OAuth server metadata missing required endpoints: " + + JSON.stringify(metadata), + ); + } + + this._cachedMetadata = metadata; + this.logger.info("OAuth endpoints discovered:", { + authorization: metadata.authorization_endpoint, + token: metadata.token_endpoint, + registration: metadata.registration_endpoint, + }); + + return metadata; + } + + /** + * Get redirect URI. + */ + private getRedirectUri(): string { + return `${vscode.env.uriScheme}://${this.extensionId}${CALLBACK_PATH}`; + } + + /** + * Load stored client registration from SecretsManager + */ + private async loadClientRegistration(): Promise { + try { + const stored = await this.secretsManager.getOAuthClientRegistration(); + if (stored) { + const registration = JSON.parse(stored) as ClientRegistrationResponse; + this._clientRegistration = registration; + this._clientId = registration.client_id; + this.logger.info("Loaded existing OAuth client:", this._clientId); + } + } catch (error) { + this.logger.error("Failed to load client registration:", error); + } + } + + /** + * Save client registration to SecretsManager + */ + private async saveClientRegistration( + registration: ClientRegistrationResponse, + ): Promise { + try { + await this.secretsManager.setOAuthClientRegistration( + JSON.stringify(registration), + ); + this._clientRegistration = registration; + this._clientId = registration.client_id; + this.logger.info("Saved OAuth client registration:", this._clientId); + } catch (error) { + this.logger.error("Failed to save client registration:", error); + } + } + + /** + * Clear stored client registration from SecretsManager + */ + async clearClientRegistration(): Promise { + await this.secretsManager.setOAuthClientRegistration(undefined); + this._clientRegistration = undefined; + this._clientId = undefined; + this.logger.info("Cleared OAuth client registration"); + } + + /** + * Register OAuth client dynamically (RFC 7591) + * Uses discovered registration endpoint from OAuth server metadata + */ + async registerClient(): Promise { + const redirectUri = this.getRedirectUri(); + + // Check if we need a new registration + if (this._clientId && this._clientRegistration) { + if (this._clientRegistration.redirect_uris.includes(redirectUri)) { + this.logger.info("Using existing client registration:", this._clientId); + return this._clientId; + } + this.logger.info("Redirect URI changed, re-registering client"); + } + + // Discover endpoints - will throw if 404 + const metadata = await this.discoverOAuthEndpoints(); + + if (!metadata.registration_endpoint) { + throw new Error( + "Server does not support dynamic client registration (no registration_endpoint in metadata)", + ); + } + + const registrationRequest: ClientRegistrationRequest = { + redirect_uris: [redirectUri], + application_type: "native", + grant_types: ["authorization_code"], + response_types: ["code"], + client_name: "VS Code Coder Extension", + token_endpoint_auth_method: "client_secret_post", + }; + + this.logger.info( + "Registering OAuth client at:", + metadata.registration_endpoint, + ); + + const response = await this.client + .getAxiosInstance() + .request({ + url: metadata.registration_endpoint, + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + data: registrationRequest, + }); + + await this.saveClientRegistration(response.data); + return response.data.client_id; + } + + /** + * Generate OAuth authorization URL with PKCE + * Uses discovered authorization endpoint + */ + private generateAuthUrl( + metadata: OAuthServerMetadata, + clientId: string, + state: string, + challenge: string, + scope = "all", + ): string { + const params = new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: this.getRedirectUri(), + scope: scope, + state: state, + code_challenge: challenge, + code_challenge_method: "S256", + }); + + const url = `${metadata.authorization_endpoint}?${params.toString()}`; + + this.logger.info("OAuth Authorization URL:", url); + this.logger.info("Client ID:", clientId); + this.logger.info("Redirect URI:", this.getRedirectUri()); + this.logger.info("Scope:", scope); + + return url; + } + + /** + * Start OAuth authorization flow + * Returns a promise that resolves when the callback is received with the authorization code + */ + async startAuthFlow( + scope = "all", + ): Promise<{ code: string; verifier: string }> { + // Discover endpoints first - will throw if 404 + const metadata = await this.discoverOAuthEndpoints(); + + // Register client + const clientId = await this.registerClient(); + + // Generate PKCE and state (kept in closure) + const state = randomBytes(16).toString("base64url"); + const { verifier, challenge } = generatePKCE(); + + // Build auth URL with discovered endpoints + const authUrl = this.generateAuthUrl( + metadata, + clientId, + state, + challenge, + scope, + ); + + // Create promise that waits for callback + return new Promise<{ code: string; verifier: string }>( + (resolve, reject) => { + const timeout = setTimeout( + () => { + this._pendingAuthResolve = undefined; + this._pendingAuthReject = undefined; + this._expectedState = undefined; + this._pendingVerifier = undefined; + reject(new Error("OAuth flow timed out after 5 minutes")); + }, + 5 * 60 * 1000, + ); + + // Store resolvers, state, and verifier for callback handler + this._pendingAuthResolve = (result) => { + clearTimeout(timeout); + this._pendingAuthResolve = undefined; + this._pendingAuthReject = undefined; + this._expectedState = undefined; + this._pendingVerifier = undefined; + resolve(result); + }; + + this._pendingAuthReject = (error) => { + clearTimeout(timeout); + this._pendingAuthResolve = undefined; + this._pendingAuthReject = undefined; + this._expectedState = undefined; + this._pendingVerifier = undefined; + reject(error); + }; + + this._expectedState = state; + this._pendingVerifier = verifier; + + vscode.env.openExternal(vscode.Uri.parse(authUrl)).then( + () => {}, + (error) => { + if (error instanceof Error) { + this._pendingAuthReject?.(error); + } else { + this._pendingAuthReject?.(new Error("Failed to open browser")); + } + }, + ); + }, + ); + } + + /** + * Handle OAuth callback from URI handler + * Called by extension.ts when vscode:// callback is received + */ + handleCallback( + code: string | null, + state: string | null, + error: string | null, + ): void { + if (!this._pendingAuthResolve || !this._pendingAuthReject) { + this.logger.warn("Received OAuth callback but no pending auth flow"); + return; + } + + if (error) { + this._pendingAuthReject(new Error(`OAuth error: ${error}`)); + return; + } + + if (!code) { + this._pendingAuthReject(new Error("No authorization code received")); + return; + } + + if (!state) { + this._pendingAuthReject(new Error("No state received")); + return; + } + + // Get verifier from pending flow + const verifier = this._pendingVerifier; + if (!verifier) { + this._pendingAuthReject(new Error("No PKCE verifier found")); + return; + } + + this._pendingAuthResolve({ code, verifier }); + } + + /** + * Exchange authorization code for access token + * Uses discovered token endpoint and PKCE verifier + */ + async exchangeCodeForToken( + code: string, + verifier: string, + ): Promise { + // Discover endpoints - will throw if 404 + const metadata = await this.discoverOAuthEndpoints(); + + if (!this._clientRegistration) { + throw new Error("No client registration found"); + } + + this.logger.info("Exchanging authorization code for token"); + + const tokenRequest = new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: this.getRedirectUri(), + client_id: this._clientRegistration.client_id, + code_verifier: verifier, + }); + + // Add client secret if present + if (this._clientRegistration.client_secret) { + tokenRequest.append( + "client_secret", + this._clientRegistration.client_secret, + ); + } + + const response = await this.client + .getAxiosInstance() + .request({ + url: metadata.token_endpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: tokenRequest.toString(), + }); + + this.logger.info("Token exchange successful"); + return response.data; + } + + getClientId(): string | undefined { + return this._clientId; + } +} + +/** + * Activate OAuth functionality + * Returns the OAuth helper instance for use by URI handler + */ +export function activateCoderOAuth( + client: CoderApi, + secretsManager: SecretsManager, + logger: Logger, + context: vscode.ExtensionContext, +): CoderOAuthHelper { + const oauthHelper = new CoderOAuthHelper( + client, + secretsManager, + logger, + context, + ); + + // Register command to test OAuth flow + context.subscriptions.push( + vscode.commands.registerCommand("coder.oauth.testAuth", async () => { + try { + // Start OAuth flow and wait for callback + const { code, verifier } = await oauthHelper.startAuthFlow(); + logger.info( + "Authorization code received:", + code.substring(0, 8) + "...", + ); + + // Exchange code for token + const tokenResponse = await oauthHelper.exchangeCodeForToken( + code, + verifier, + ); + + vscode.window.showInformationMessage( + `OAuth flow completed! Access token received (expires in ${tokenResponse.expires_in}s)`, + ); + logger.info("OAuth flow completed:", { + token_type: tokenResponse.token_type, + expires_in: tokenResponse.expires_in, + scope: tokenResponse.scope, + }); + + client.setSessionToken(tokenResponse.access_token); + const response = await client.getWorkspaces({ q: "owner:me" }); + logger.info(response.workspaces.map((w) => w.name).toString()); + } catch (error) { + vscode.window.showErrorMessage(`OAuth flow failed: ${error}`); + logger.error("OAuth flow failed:", error); + } + }), + ); + + return oauthHelper; +} From 13c876f3f7a6cab6031d1bc6984eaa58e8b74ff2 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 21 Oct 2025 23:37:53 +0300 Subject: [PATCH 02/11] Refactoring into seperate files --- src/core/secretsManager.ts | 29 ++- src/extension.ts | 10 +- src/oauth.ts | 492 ------------------------------------- src/oauth/oauthHelper.ts | 465 +++++++++++++++++++++++++++++++++++ src/oauth/types.ts | 58 +++++ src/oauth/utils.ts | 28 +++ 6 files changed, 580 insertions(+), 502 deletions(-) delete mode 100644 src/oauth.ts create mode 100644 src/oauth/oauthHelper.ts create mode 100644 src/oauth/types.ts create mode 100644 src/oauth/utils.ts diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 94982040..4821b962 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -1,3 +1,5 @@ +import { type ClientRegistrationResponse } from "../oauth/types"; + import type { SecretStorage, Disposable } from "vscode"; const SESSION_TOKEN_KEY = "sessionToken"; @@ -19,10 +21,10 @@ export class SecretsManager { * Set or unset the last used token. */ public async setSessionToken(sessionToken?: string): Promise { - if (!sessionToken) { - await this.secrets.delete(SESSION_TOKEN_KEY); - } else { + if (sessionToken) { await this.secrets.store(SESSION_TOKEN_KEY, sessionToken); + } else { + await this.secrets.delete(SESSION_TOKEN_KEY); } } @@ -77,10 +79,13 @@ export class SecretsManager { * Store OAuth client registration data. */ public async setOAuthClientRegistration( - registration: string | undefined, + registration: ClientRegistrationResponse | undefined, ): Promise { if (registration) { - await this.secrets.store(OAUTH_CLIENT_REGISTRATION_KEY, registration); + await this.secrets.store( + OAUTH_CLIENT_REGISTRATION_KEY, + JSON.stringify(registration), + ); } else { await this.secrets.delete(OAUTH_CLIENT_REGISTRATION_KEY); } @@ -89,11 +94,19 @@ export class SecretsManager { /** * Get OAuth client registration data. */ - public async getOAuthClientRegistration(): Promise { + public async getOAuthClientRegistration(): Promise< + ClientRegistrationResponse | undefined + > { try { - return await this.secrets.get(OAUTH_CLIENT_REGISTRATION_KEY); + const stringifiedResponse = await this.secrets.get( + OAUTH_CLIENT_REGISTRATION_KEY, + ); + if (stringifiedResponse) { + return JSON.parse(stringifiedResponse) as ClientRegistrationResponse; + } } catch { - return undefined; + // Do nothing } + return undefined; } } diff --git a/src/extension.ts b/src/extension.ts index effa1ed0..b6285708 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,7 +12,8 @@ import { Commands } from "./commands"; import { ServiceContainer } from "./core/container"; import { AuthAction } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; -import { activateCoderOAuth, CALLBACK_PATH } from "./oauth"; +import { activateCoderOAuth } from "./oauth/oauthHelper"; +import { CALLBACK_PATH } from "./oauth/utils"; import { Remote } from "./remote/remote"; import { toSafeHost } from "./util"; import { @@ -122,7 +123,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.subscriptions, ); - const oauthHelper = activateCoderOAuth(client, secretsManager, output, ctx); + const oauthHelper = await activateCoderOAuth( + client, + secretsManager, + output, + ctx, + ); // Handle vscode:// URIs. const uriHandler = vscode.window.registerUriHandler({ diff --git a/src/oauth.ts b/src/oauth.ts deleted file mode 100644 index 4575451d..00000000 --- a/src/oauth.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import * as vscode from "vscode"; - -import { type CoderApi } from "./api/coderApi"; -import { type SecretsManager } from "./core/secretsManager"; - -import type { Logger } from "./logging/logger"; - -export const CALLBACK_PATH = "/oauth/callback"; - -interface ClientRegistrationRequest { - redirect_uris: string[]; - application_type: "native" | "web"; - grant_types: string[]; - response_types: string[]; - client_name: string; - token_endpoint_auth_method: - | "none" - | "client_secret_post" - | "client_secret_basic"; -} - -interface ClientRegistrationResponse { - client_id: string; - client_secret?: string; - client_id_issued_at?: number; - client_secret_expires_at?: number; - redirect_uris: string[]; - grant_types: string[]; -} - -interface OAuthServerMetadata { - issuer: string; - authorization_endpoint: string; - token_endpoint: string; - registration_endpoint?: string; - response_types_supported?: string[]; - grant_types_supported?: string[]; - code_challenge_methods_supported?: string[]; -} - -interface TokenResponse { - access_token: string; - token_type: string; - expires_in?: number; - refresh_token?: string; - scope?: string; -} - -/** - * Generate PKCE verifier and challenge (RFC 7636) - */ -function generatePKCE(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("base64url"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - -/** - * OAuth helper for Coder authentication - */ -class CoderOAuthHelper { - private _clientId: string | undefined; - private _clientRegistration: ClientRegistrationResponse | undefined; - private _cachedMetadata: OAuthServerMetadata | undefined; - private _pendingAuthResolve: - | ((value: { code: string; verifier: string }) => void) - | undefined; - private _pendingAuthReject: ((reason: Error) => void) | undefined; - private _expectedState: string | undefined; - private _pendingVerifier: string | undefined; - - private readonly extensionId: string; - - constructor( - private readonly client: CoderApi, - private readonly secretsManager: SecretsManager, - private readonly logger: Logger, - context: vscode.ExtensionContext, - ) { - this.loadClientRegistration(); - this.extensionId = context.extension.id; - } - - /** - * Discover OAuth server endpoints using RFC 8414 - * Caches result in memory for the session - * Throws error if server returns 404 (OAuth not supported) - */ - private async discoverOAuthEndpoints(): Promise { - if (this._cachedMetadata) { - return this._cachedMetadata; - } - - this.logger.info("Discovering OAuth endpoints..."); - - const response = await this.client - .getAxiosInstance() - .request({ - url: "/.well-known/oauth-authorization-server", - method: "GET", - headers: { - Accept: "application/json", - }, - }); - - const metadata = response.data; - - // Validate required fields - if ( - !metadata.authorization_endpoint || - !metadata.token_endpoint || - !metadata.issuer - ) { - throw new Error( - "OAuth server metadata missing required endpoints: " + - JSON.stringify(metadata), - ); - } - - this._cachedMetadata = metadata; - this.logger.info("OAuth endpoints discovered:", { - authorization: metadata.authorization_endpoint, - token: metadata.token_endpoint, - registration: metadata.registration_endpoint, - }); - - return metadata; - } - - /** - * Get redirect URI. - */ - private getRedirectUri(): string { - return `${vscode.env.uriScheme}://${this.extensionId}${CALLBACK_PATH}`; - } - - /** - * Load stored client registration from SecretsManager - */ - private async loadClientRegistration(): Promise { - try { - const stored = await this.secretsManager.getOAuthClientRegistration(); - if (stored) { - const registration = JSON.parse(stored) as ClientRegistrationResponse; - this._clientRegistration = registration; - this._clientId = registration.client_id; - this.logger.info("Loaded existing OAuth client:", this._clientId); - } - } catch (error) { - this.logger.error("Failed to load client registration:", error); - } - } - - /** - * Save client registration to SecretsManager - */ - private async saveClientRegistration( - registration: ClientRegistrationResponse, - ): Promise { - try { - await this.secretsManager.setOAuthClientRegistration( - JSON.stringify(registration), - ); - this._clientRegistration = registration; - this._clientId = registration.client_id; - this.logger.info("Saved OAuth client registration:", this._clientId); - } catch (error) { - this.logger.error("Failed to save client registration:", error); - } - } - - /** - * Clear stored client registration from SecretsManager - */ - async clearClientRegistration(): Promise { - await this.secretsManager.setOAuthClientRegistration(undefined); - this._clientRegistration = undefined; - this._clientId = undefined; - this.logger.info("Cleared OAuth client registration"); - } - - /** - * Register OAuth client dynamically (RFC 7591) - * Uses discovered registration endpoint from OAuth server metadata - */ - async registerClient(): Promise { - const redirectUri = this.getRedirectUri(); - - // Check if we need a new registration - if (this._clientId && this._clientRegistration) { - if (this._clientRegistration.redirect_uris.includes(redirectUri)) { - this.logger.info("Using existing client registration:", this._clientId); - return this._clientId; - } - this.logger.info("Redirect URI changed, re-registering client"); - } - - // Discover endpoints - will throw if 404 - const metadata = await this.discoverOAuthEndpoints(); - - if (!metadata.registration_endpoint) { - throw new Error( - "Server does not support dynamic client registration (no registration_endpoint in metadata)", - ); - } - - const registrationRequest: ClientRegistrationRequest = { - redirect_uris: [redirectUri], - application_type: "native", - grant_types: ["authorization_code"], - response_types: ["code"], - client_name: "VS Code Coder Extension", - token_endpoint_auth_method: "client_secret_post", - }; - - this.logger.info( - "Registering OAuth client at:", - metadata.registration_endpoint, - ); - - const response = await this.client - .getAxiosInstance() - .request({ - url: metadata.registration_endpoint, - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - data: registrationRequest, - }); - - await this.saveClientRegistration(response.data); - return response.data.client_id; - } - - /** - * Generate OAuth authorization URL with PKCE - * Uses discovered authorization endpoint - */ - private generateAuthUrl( - metadata: OAuthServerMetadata, - clientId: string, - state: string, - challenge: string, - scope = "all", - ): string { - const params = new URLSearchParams({ - client_id: clientId, - response_type: "code", - redirect_uri: this.getRedirectUri(), - scope: scope, - state: state, - code_challenge: challenge, - code_challenge_method: "S256", - }); - - const url = `${metadata.authorization_endpoint}?${params.toString()}`; - - this.logger.info("OAuth Authorization URL:", url); - this.logger.info("Client ID:", clientId); - this.logger.info("Redirect URI:", this.getRedirectUri()); - this.logger.info("Scope:", scope); - - return url; - } - - /** - * Start OAuth authorization flow - * Returns a promise that resolves when the callback is received with the authorization code - */ - async startAuthFlow( - scope = "all", - ): Promise<{ code: string; verifier: string }> { - // Discover endpoints first - will throw if 404 - const metadata = await this.discoverOAuthEndpoints(); - - // Register client - const clientId = await this.registerClient(); - - // Generate PKCE and state (kept in closure) - const state = randomBytes(16).toString("base64url"); - const { verifier, challenge } = generatePKCE(); - - // Build auth URL with discovered endpoints - const authUrl = this.generateAuthUrl( - metadata, - clientId, - state, - challenge, - scope, - ); - - // Create promise that waits for callback - return new Promise<{ code: string; verifier: string }>( - (resolve, reject) => { - const timeout = setTimeout( - () => { - this._pendingAuthResolve = undefined; - this._pendingAuthReject = undefined; - this._expectedState = undefined; - this._pendingVerifier = undefined; - reject(new Error("OAuth flow timed out after 5 minutes")); - }, - 5 * 60 * 1000, - ); - - // Store resolvers, state, and verifier for callback handler - this._pendingAuthResolve = (result) => { - clearTimeout(timeout); - this._pendingAuthResolve = undefined; - this._pendingAuthReject = undefined; - this._expectedState = undefined; - this._pendingVerifier = undefined; - resolve(result); - }; - - this._pendingAuthReject = (error) => { - clearTimeout(timeout); - this._pendingAuthResolve = undefined; - this._pendingAuthReject = undefined; - this._expectedState = undefined; - this._pendingVerifier = undefined; - reject(error); - }; - - this._expectedState = state; - this._pendingVerifier = verifier; - - vscode.env.openExternal(vscode.Uri.parse(authUrl)).then( - () => {}, - (error) => { - if (error instanceof Error) { - this._pendingAuthReject?.(error); - } else { - this._pendingAuthReject?.(new Error("Failed to open browser")); - } - }, - ); - }, - ); - } - - /** - * Handle OAuth callback from URI handler - * Called by extension.ts when vscode:// callback is received - */ - handleCallback( - code: string | null, - state: string | null, - error: string | null, - ): void { - if (!this._pendingAuthResolve || !this._pendingAuthReject) { - this.logger.warn("Received OAuth callback but no pending auth flow"); - return; - } - - if (error) { - this._pendingAuthReject(new Error(`OAuth error: ${error}`)); - return; - } - - if (!code) { - this._pendingAuthReject(new Error("No authorization code received")); - return; - } - - if (!state) { - this._pendingAuthReject(new Error("No state received")); - return; - } - - // Get verifier from pending flow - const verifier = this._pendingVerifier; - if (!verifier) { - this._pendingAuthReject(new Error("No PKCE verifier found")); - return; - } - - this._pendingAuthResolve({ code, verifier }); - } - - /** - * Exchange authorization code for access token - * Uses discovered token endpoint and PKCE verifier - */ - async exchangeCodeForToken( - code: string, - verifier: string, - ): Promise { - // Discover endpoints - will throw if 404 - const metadata = await this.discoverOAuthEndpoints(); - - if (!this._clientRegistration) { - throw new Error("No client registration found"); - } - - this.logger.info("Exchanging authorization code for token"); - - const tokenRequest = new URLSearchParams({ - grant_type: "authorization_code", - code: code, - redirect_uri: this.getRedirectUri(), - client_id: this._clientRegistration.client_id, - code_verifier: verifier, - }); - - // Add client secret if present - if (this._clientRegistration.client_secret) { - tokenRequest.append( - "client_secret", - this._clientRegistration.client_secret, - ); - } - - const response = await this.client - .getAxiosInstance() - .request({ - url: metadata.token_endpoint, - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - data: tokenRequest.toString(), - }); - - this.logger.info("Token exchange successful"); - return response.data; - } - - getClientId(): string | undefined { - return this._clientId; - } -} - -/** - * Activate OAuth functionality - * Returns the OAuth helper instance for use by URI handler - */ -export function activateCoderOAuth( - client: CoderApi, - secretsManager: SecretsManager, - logger: Logger, - context: vscode.ExtensionContext, -): CoderOAuthHelper { - const oauthHelper = new CoderOAuthHelper( - client, - secretsManager, - logger, - context, - ); - - // Register command to test OAuth flow - context.subscriptions.push( - vscode.commands.registerCommand("coder.oauth.testAuth", async () => { - try { - // Start OAuth flow and wait for callback - const { code, verifier } = await oauthHelper.startAuthFlow(); - logger.info( - "Authorization code received:", - code.substring(0, 8) + "...", - ); - - // Exchange code for token - const tokenResponse = await oauthHelper.exchangeCodeForToken( - code, - verifier, - ); - - vscode.window.showInformationMessage( - `OAuth flow completed! Access token received (expires in ${tokenResponse.expires_in}s)`, - ); - logger.info("OAuth flow completed:", { - token_type: tokenResponse.token_type, - expires_in: tokenResponse.expires_in, - scope: tokenResponse.scope, - }); - - client.setSessionToken(tokenResponse.access_token); - const response = await client.getWorkspaces({ q: "owner:me" }); - logger.info(response.workspaces.map((w) => w.name).toString()); - } catch (error) { - vscode.window.showErrorMessage(`OAuth flow failed: ${error}`); - logger.error("OAuth flow failed:", error); - } - }), - ); - - return oauthHelper; -} diff --git a/src/oauth/oauthHelper.ts b/src/oauth/oauthHelper.ts new file mode 100644 index 00000000..54c87c15 --- /dev/null +++ b/src/oauth/oauthHelper.ts @@ -0,0 +1,465 @@ +import * as vscode from "vscode"; + +import { type CoderApi } from "../api/coderApi"; +import { type SecretsManager } from "../core/secretsManager"; + +import { CALLBACK_PATH, generatePKCE, generateState } from "./utils"; + +import type { Logger } from "../logging/logger"; + +import type { + AuthorizationRequestParams, + ClientRegistrationRequest, + ClientRegistrationResponse, + OAuthServerMetadata, + TokenRequestParams, + TokenResponse, +} from "./types"; + +const OAUTH_GRANT_TYPE = "authorization_code" as const; +const OAUTH_RESPONSE_TYPE = "code" as const; +const OAUTH_AUTH_METHOD = "client_secret_post" as const; +const PKCE_CHALLENGE_METHOD = "S256" as const; +const CLIENT_NAME = "VS Code Coder Extension"; + +export class CoderOAuthHelper { + private clientRegistration: ClientRegistrationResponse | undefined; + private cachedMetadata: OAuthServerMetadata | undefined; + private pendingAuthResolve: + | ((value: { code: string; verifier: string }) => void) + | undefined; + private pendingAuthReject: ((reason: Error) => void) | undefined; + private expectedState: string | undefined; + private pendingVerifier: string | undefined; + + private readonly extensionId: string; + + static async create( + client: CoderApi, + secretsManager: SecretsManager, + logger: Logger, + context: vscode.ExtensionContext, + ): Promise { + const helper = new CoderOAuthHelper( + client, + secretsManager, + logger, + context, + ); + await helper.loadRegistration(); + return helper; + } + private constructor( + private readonly client: CoderApi, + private readonly secretsManager: SecretsManager, + private readonly logger: Logger, + context: vscode.ExtensionContext, + ) { + this.extensionId = context.extension.id; + } + + private async getMetadata(): Promise { + if (this.cachedMetadata) { + return this.cachedMetadata; + } + + this.logger.info("Discovering OAuth endpoints..."); + + const response = await this.client + .getAxiosInstance() + .request({ + url: "/.well-known/oauth-authorization-server", + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + const metadata = response.data; + + if ( + !metadata.authorization_endpoint || + !metadata.token_endpoint || + !metadata.issuer + ) { + throw new Error( + "OAuth server metadata missing required endpoints: " + + JSON.stringify(metadata), + ); + } + + if ( + !includesAllTypes(metadata.grant_types_supported, [ + OAUTH_GRANT_TYPE, + "refresh_token", + ]) + ) { + throw new Error( + `Server does not support required grant types: authorization_code, refresh_token. Supported: ${metadata.grant_types_supported?.join(", ") || "none"}`, + ); + } + + if ( + !includesAllTypes(metadata.response_types_supported, [ + OAUTH_RESPONSE_TYPE, + ]) + ) { + throw new Error( + `Server does not support required response type: code. Supported: ${metadata.response_types_supported?.join(", ") || "none"}`, + ); + } + + if ( + !includesAllTypes(metadata.token_endpoint_auth_methods_supported, [ + OAUTH_AUTH_METHOD, + ]) + ) { + throw new Error( + `Server does not support required auth method: client_secret_post. Supported: ${metadata.token_endpoint_auth_methods_supported?.join(", ") || "none"}`, + ); + } + + if ( + !includesAllTypes(metadata.code_challenge_methods_supported, [ + PKCE_CHALLENGE_METHOD, + ]) + ) { + throw new Error( + `Server does not support required PKCE method: S256. Supported: ${metadata.code_challenge_methods_supported?.join(", ") || "none"}`, + ); + } + + this.cachedMetadata = metadata; + this.logger.info("OAuth endpoints discovered:", { + authorization: metadata.authorization_endpoint, + token: metadata.token_endpoint, + registration: metadata.registration_endpoint, + }); + + return metadata; + } + + private getRedirectUri(): string { + return `${vscode.env.uriScheme}://${this.extensionId}${CALLBACK_PATH}`; + } + + private async loadRegistration(): Promise { + const registration = await this.secretsManager.getOAuthClientRegistration(); + if (registration) { + this.clientRegistration = registration; + this.logger.info("Loaded existing OAuth client:", registration.client_id); + } + } + + private async saveRegistration( + registration: ClientRegistrationResponse, + ): Promise { + await this.secretsManager.setOAuthClientRegistration(registration); + this.clientRegistration = registration; + this.logger.info( + "Saved OAuth client registration:", + registration.client_id, + ); + } + + async clearClientRegistration(): Promise { + await this.secretsManager.setOAuthClientRegistration(undefined); + this.clientRegistration = undefined; + this.logger.info("Cleared OAuth client registration"); + } + + async registerClient(): Promise { + const redirectUri = this.getRedirectUri(); + + if (this.clientRegistration?.client_id) { + const clientId = this.clientRegistration.client_id; + if (this.clientRegistration.redirect_uris.includes(redirectUri)) { + this.logger.info("Using existing client registration:", clientId); + return clientId; + } + this.logger.info("Redirect URI changed, re-registering client"); + } + + const metadata = await this.getMetadata(); + + if (!metadata.registration_endpoint) { + throw new Error( + "Server does not support dynamic client registration (no registration_endpoint in metadata)", + ); + } + + const registrationRequest: ClientRegistrationRequest = { + redirect_uris: [redirectUri], + application_type: "native", + grant_types: [OAUTH_GRANT_TYPE], + response_types: [OAUTH_RESPONSE_TYPE], + client_name: CLIENT_NAME, + token_endpoint_auth_method: OAUTH_AUTH_METHOD, + }; + + const response = await this.client + .getAxiosInstance() + .request({ + url: metadata.registration_endpoint, + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + data: registrationRequest, + }); + + await this.saveRegistration(response.data); + return response.data.client_id; + } + + private buildAuthorizationUrl( + metadata: OAuthServerMetadata, + clientId: string, + state: string, + challenge: string, + scope = "all", + ): string { + if ( + metadata.scopes_supported && + !metadata.scopes_supported.includes(scope) + ) { + this.logger.warn( + `Requested scope "${scope}" not in server's supported scopes. Server may still accept it.`, + { supported_scopes: metadata.scopes_supported }, + ); + } + + const params: AuthorizationRequestParams = { + client_id: clientId, + response_type: OAUTH_RESPONSE_TYPE, + redirect_uri: this.getRedirectUri(), + scope, + state, + code_challenge: challenge, + code_challenge_method: PKCE_CHALLENGE_METHOD, + }; + + const url = `${metadata.authorization_endpoint}?${new URLSearchParams(params as unknown as Record).toString()}`; + + this.logger.info("OAuth Authorization URL:", url); + this.logger.info("Client ID:", clientId); + this.logger.info("Redirect URI:", this.getRedirectUri()); + this.logger.info("Scope:", scope); + + return url; + } + + async startAuthorization( + scope = "all", + ): Promise<{ code: string; verifier: string }> { + const metadata = await this.getMetadata(); + const clientId = await this.registerClient(); + const state = generateState(); + const { verifier, challenge } = generatePKCE(); + + const authUrl = this.buildAuthorizationUrl( + metadata, + clientId, + state, + challenge, + scope, + ); + + return new Promise<{ code: string; verifier: string }>( + (resolve, reject) => { + const timeout = setTimeout( + () => { + this.clearPendingAuth(); + reject(new Error("OAuth flow timed out after 5 minutes")); + }, + 5 * 60 * 1000, + ); + + const clearPromise = () => { + clearTimeout(timeout); + this.clearPendingAuth(); + }; + + this.pendingAuthResolve = (result) => { + clearPromise(); + resolve(result); + }; + + this.pendingAuthReject = (error) => { + clearPromise(); + reject(error); + }; + + this.expectedState = state; + this.pendingVerifier = verifier; + + vscode.env.openExternal(vscode.Uri.parse(authUrl)).then( + () => {}, + (error) => { + if (error instanceof Error) { + this.pendingAuthReject?.(error); + } else { + this.pendingAuthReject?.(new Error("Failed to open browser")); + } + }, + ); + }, + ); + } + + private clearPendingAuth(): void { + this.pendingAuthResolve = undefined; + this.pendingAuthReject = undefined; + this.expectedState = undefined; + this.pendingVerifier = undefined; + } + + handleCallback( + code: string | null, + state: string | null, + error: string | null, + ): void { + if (!this.pendingAuthResolve || !this.pendingAuthReject) { + this.logger.warn("Received OAuth callback but no pending auth flow"); + return; + } + + if (error) { + this.pendingAuthReject(new Error(`OAuth error: ${error}`)); + return; + } + + if (!code) { + this.pendingAuthReject(new Error("No authorization code received")); + return; + } + + if (!state) { + this.pendingAuthReject(new Error("No state received")); + return; + } + + if (state !== this.expectedState) { + this.pendingAuthReject( + new Error("State mismatch - possible CSRF attack"), + ); + return; + } + + const verifier = this.pendingVerifier; + if (!verifier) { + this.pendingAuthReject(new Error("No PKCE verifier found")); + return; + } + + this.pendingAuthResolve({ code, verifier }); + } + + async exchangeToken(code: string, verifier: string): Promise { + const metadata = await this.getMetadata(); + + if (!this.clientRegistration) { + throw new Error("No client registration found"); + } + + this.logger.info("Exchanging authorization code for token"); + + const params: TokenRequestParams = { + grant_type: OAUTH_GRANT_TYPE, + code, + redirect_uri: this.getRedirectUri(), + client_id: this.clientRegistration.client_id, + code_verifier: verifier, + }; + + if (this.clientRegistration.client_secret) { + params.client_secret = this.clientRegistration.client_secret; + } + + const tokenRequest = new URLSearchParams( + params as unknown as Record, + ); + + const response = await this.client + .getAxiosInstance() + .request({ + url: metadata.token_endpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: tokenRequest.toString(), + }); + + this.logger.info("Token exchange successful"); + return response.data; + } + + getClientId(): string | undefined { + return this.clientRegistration?.client_id; + } +} + +function includesAllTypes( + arr: string[] | undefined, + requiredTypes: readonly string[], +): boolean { + if (arr === undefined) { + // Supported types are not sent by the server so just assume everything is allowed + return true; + } + + return requiredTypes.every((type) => arr.includes(type)); +} + +/** + * Activates OAuth support for the Coder extension. + * Initializes the OAuth helper and registers the test auth command. + */ +export async function activateCoderOAuth( + client: CoderApi, + secretsManager: SecretsManager, + logger: Logger, + context: vscode.ExtensionContext, +): Promise { + const oauthHelper = await CoderOAuthHelper.create( + client, + secretsManager, + logger, + context, + ); + + context.subscriptions.push( + vscode.commands.registerCommand("coder.oauth.testAuth", async () => { + try { + const { code, verifier } = await oauthHelper.startAuthorization(); + logger.info( + "Authorization code received:", + code.substring(0, 8) + "...", + ); + + const tokenResponse = await oauthHelper.exchangeToken(code, verifier); + + vscode.window.showInformationMessage( + `OAuth flow completed! Access token received (expires in ${tokenResponse.expires_in}s)`, + ); + logger.info("OAuth flow completed:", { + token_type: tokenResponse.token_type, + expires_in: tokenResponse.expires_in, + scope: tokenResponse.scope, + }); + + client.setSessionToken(tokenResponse.access_token); + const response = await client.getWorkspaces({ q: "owner:me" }); + logger.info(response.workspaces.map((w) => w.name).toString()); + } catch (error) { + vscode.window.showErrorMessage(`OAuth flow failed: ${error}`); + logger.error("OAuth flow failed:", error); + } + }), + ); + + return oauthHelper; +} diff --git a/src/oauth/types.ts b/src/oauth/types.ts new file mode 100644 index 00000000..493e3707 --- /dev/null +++ b/src/oauth/types.ts @@ -0,0 +1,58 @@ +export interface ClientRegistrationRequest { + redirect_uris: string[]; + token_endpoint_auth_method: "client_secret_post"; + application_type: "native" | "web"; + grant_types: string[]; + response_types: string[]; + client_name?: string; + client_uri?: string; + scope?: string[]; +} + +export interface ClientRegistrationResponse { + client_id: string; + client_secret?: string; + client_id_issued_at?: number; + client_secret_expires_at?: number; + redirect_uris: string[]; + grant_types: string[]; +} + +export interface OAuthServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint?: string; + response_types_supported?: string[]; + grant_types_supported?: string[]; + code_challenge_methods_supported?: string[]; + scopes_supported?: string[]; + token_endpoint_auth_methods_supported?: string[]; +} + +export interface TokenResponse { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; +} + +export interface AuthorizationRequestParams { + client_id: string; + response_type: "code"; + redirect_uri: string; + scope: string; + state: string; + code_challenge: string; + code_challenge_method: "S256"; +} + +export interface TokenRequestParams { + grant_type: "authorization_code"; + code: string; + redirect_uri: string; + client_id: string; + code_verifier: string; + client_secret?: string; +} diff --git a/src/oauth/utils.ts b/src/oauth/utils.ts new file mode 100644 index 00000000..7d66a139 --- /dev/null +++ b/src/oauth/utils.ts @@ -0,0 +1,28 @@ +import { createHash, randomBytes } from "node:crypto"; + +/** + * OAuth callback path for handling authorization responses (RFC 6749). + */ +export const CALLBACK_PATH = "/oauth/callback"; + +export interface PKCEChallenge { + verifier: string; + challenge: string; +} + +/** + * Generates a PKCE challenge pair (RFC 7636). + * Creates a code verifier and its SHA256 challenge for secure OAuth flows. + */ +export function generatePKCE(): PKCEChallenge { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +/** + * Generates a cryptographically secure state parameter to prevent CSRF attacks (RFC 6749). + */ +export function generateState(): string { + return randomBytes(16).toString("base64url"); +} From 455ad34fd65c57e6d6eeee32f32ff6ad13ffa864 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 22 Oct 2025 10:33:01 +0300 Subject: [PATCH 03/11] Update types --- src/oauth/types.ts | 133 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 14 deletions(-) diff --git a/src/oauth/types.ts b/src/oauth/types.ts index 493e3707..6ecaa0ff 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -1,53 +1,112 @@ +// OAuth 2.1 Grant Types +export type GrantType = + | "authorization_code" + | "refresh_token" + | "client_credentials"; + +// OAuth 2.1 Response Types +export type ResponseType = "code"; + +// Token Endpoint Authentication Methods +export type TokenEndpointAuthMethod = + | "client_secret_post" + | "client_secret_basic" + | "none"; + +// Application Types +export type ApplicationType = "native" | "web"; + +// PKCE Code Challenge Methods (OAuth 2.1 requires S256) +export type CodeChallengeMethod = "S256"; + +// Token Types +export type TokenType = "Bearer" | "DPoP"; + +// Client Registration Request (RFC 7591 + OAuth 2.1) export interface ClientRegistrationRequest { redirect_uris: string[]; - token_endpoint_auth_method: "client_secret_post"; - application_type: "native" | "web"; - grant_types: string[]; - response_types: string[]; + token_endpoint_auth_method: TokenEndpointAuthMethod; + application_type: ApplicationType; + grant_types: GrantType[]; + response_types: ResponseType[]; client_name?: string; client_uri?: string; - scope?: string[]; + logo_uri?: string; + scope?: string; + contacts?: string[]; + tos_uri?: string; + policy_uri?: string; + jwks_uri?: string; + software_id?: string; + software_version?: string; } +// Client Registration Response (RFC 7591) export interface ClientRegistrationResponse { client_id: string; client_secret?: string; client_id_issued_at?: number; client_secret_expires_at?: number; redirect_uris: string[]; - grant_types: string[]; + token_endpoint_auth_method: TokenEndpointAuthMethod; + application_type?: ApplicationType; + grant_types: GrantType[]; + response_types: ResponseType[]; + client_name?: string; + client_uri?: string; + logo_uri?: string; + scope?: string; + contacts?: string[]; + tos_uri?: string; + policy_uri?: string; + jwks_uri?: string; + software_id?: string; + software_version?: string; + registration_client_uri?: string; + registration_access_token?: string; } +// OAuth 2.1 Authorization Server Metadata (RFC 8414) export interface OAuthServerMetadata { issuer: string; authorization_endpoint: string; token_endpoint: string; registration_endpoint?: string; - response_types_supported?: string[]; - grant_types_supported?: string[]; - code_challenge_methods_supported?: string[]; + jwks_uri?: string; + response_types_supported: ResponseType[]; + grant_types_supported?: GrantType[]; + code_challenge_methods_supported: CodeChallengeMethod[]; scopes_supported?: string[]; - token_endpoint_auth_methods_supported?: string[]; + token_endpoint_auth_methods_supported?: TokenEndpointAuthMethod[]; + revocation_endpoint?: string; + revocation_endpoint_auth_methods_supported?: TokenEndpointAuthMethod[]; + introspection_endpoint?: string; + introspection_endpoint_auth_methods_supported?: TokenEndpointAuthMethod[]; + service_documentation?: string; + ui_locales_supported?: string[]; } +// Token Response (RFC 6749 Section 5.1) export interface TokenResponse { access_token: string; - token_type: string; + token_type: TokenType; expires_in?: number; refresh_token?: string; scope?: string; } +// Authorization Request Parameters (OAuth 2.1) export interface AuthorizationRequestParams { client_id: string; - response_type: "code"; + response_type: ResponseType; redirect_uri: string; - scope: string; + scope?: string; state: string; code_challenge: string; - code_challenge_method: "S256"; + code_challenge_method: CodeChallengeMethod; } +// Token Request Parameters - Authorization Code Grant (OAuth 2.1) export interface TokenRequestParams { grant_type: "authorization_code"; code: string; @@ -56,3 +115,49 @@ export interface TokenRequestParams { code_verifier: string; client_secret?: string; } + +// Token Request Parameters - Refresh Token Grant +export interface RefreshTokenRequestParams { + grant_type: "refresh_token"; + refresh_token: string; + client_id: string; + client_secret?: string; + scope?: string; +} + +// Token Request Parameters - Client Credentials Grant +export interface ClientCredentialsRequestParams { + grant_type: "client_credentials"; + client_id: string; + client_secret: string; + scope?: string; +} + +// Union type for all token request types +export type TokenRequestParamsUnion = + | TokenRequestParams + | RefreshTokenRequestParams + | ClientCredentialsRequestParams; + +// Token Revocation Request (RFC 7009) +export interface TokenRevocationRequest { + token: string; + token_type_hint?: "access_token" | "refresh_token"; + client_id: string; + client_secret?: string; +} + +// Error Response (RFC 6749 Section 5.2) +export interface OAuthErrorResponse { + error: + | "invalid_request" + | "invalid_client" + | "invalid_grant" + | "unauthorized_client" + | "unsupported_grant_type" + | "invalid_scope" + | "server_error" + | "temporarily_unavailable"; + error_description?: string; + error_uri?: string; +} From 7127867498e6c8a39cfad9dd458def9736361b69 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 22 Oct 2025 16:18:31 +0300 Subject: [PATCH 04/11] Smol refactorings --- src/oauth/oauthHelper.ts | 83 ++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 49 deletions(-) diff --git a/src/oauth/oauthHelper.ts b/src/oauth/oauthHelper.ts index 54c87c15..bbd0a82c 100644 --- a/src/oauth/oauthHelper.ts +++ b/src/oauth/oauthHelper.ts @@ -16,12 +16,15 @@ import type { TokenResponse, } from "./types"; -const OAUTH_GRANT_TYPE = "authorization_code" as const; -const OAUTH_RESPONSE_TYPE = "code" as const; -const OAUTH_AUTH_METHOD = "client_secret_post" as const; +const AUTH_GRANT_TYPE = "authorization_code" as const; +const REFRESH_GRANT_TYPE = "refresh_token" as const; +const RESPONSE_TYPE = "code" as const; +const OAUTH_METHOD = "client_secret_post" as const; const PKCE_CHALLENGE_METHOD = "S256" as const; const CLIENT_NAME = "VS Code Coder Extension"; +const REQUIRED_GRANT_TYPES = [AUTH_GRANT_TYPE, REFRESH_GRANT_TYPE] as const; + export class CoderOAuthHelper { private clientRegistration: ClientRegistrationResponse | undefined; private cachedMetadata: OAuthServerMetadata | undefined; @@ -46,7 +49,7 @@ export class CoderOAuthHelper { logger, context, ); - await helper.loadRegistration(); + await helper.loadClientRegistration(); return helper; } private constructor( @@ -67,13 +70,7 @@ export class CoderOAuthHelper { const response = await this.client .getAxiosInstance() - .request({ - url: "/.well-known/oauth-authorization-server", - method: "GET", - headers: { - Accept: "application/json", - }, - }); + .get("/.well-known/oauth-authorization-server"); const metadata = response.data; @@ -89,33 +86,26 @@ export class CoderOAuthHelper { } if ( - !includesAllTypes(metadata.grant_types_supported, [ - OAUTH_GRANT_TYPE, - "refresh_token", - ]) + !includesAllTypes(metadata.grant_types_supported, REQUIRED_GRANT_TYPES) ) { throw new Error( - `Server does not support required grant types: authorization_code, refresh_token. Supported: ${metadata.grant_types_supported?.join(", ") || "none"}`, + `Server does not support required grant types: ${REQUIRED_GRANT_TYPES.join(", ")}. Supported: ${metadata.grant_types_supported?.join(", ") || "none"}`, ); } - if ( - !includesAllTypes(metadata.response_types_supported, [ - OAUTH_RESPONSE_TYPE, - ]) - ) { + if (!includesAllTypes(metadata.response_types_supported, [RESPONSE_TYPE])) { throw new Error( - `Server does not support required response type: code. Supported: ${metadata.response_types_supported?.join(", ") || "none"}`, + `Server does not support required response type: ${RESPONSE_TYPE}. Supported: ${metadata.response_types_supported?.join(", ") || "none"}`, ); } if ( !includesAllTypes(metadata.token_endpoint_auth_methods_supported, [ - OAUTH_AUTH_METHOD, + OAUTH_METHOD, ]) ) { throw new Error( - `Server does not support required auth method: client_secret_post. Supported: ${metadata.token_endpoint_auth_methods_supported?.join(", ") || "none"}`, + `Server does not support required auth method: ${OAUTH_METHOD}. Supported: ${metadata.token_endpoint_auth_methods_supported?.join(", ") || "none"}`, ); } @@ -125,7 +115,7 @@ export class CoderOAuthHelper { ]) ) { throw new Error( - `Server does not support required PKCE method: S256. Supported: ${metadata.code_challenge_methods_supported?.join(", ") || "none"}`, + `Server does not support required PKCE method: ${PKCE_CHALLENGE_METHOD}. Supported: ${metadata.code_challenge_methods_supported?.join(", ") || "none"}`, ); } @@ -143,7 +133,7 @@ export class CoderOAuthHelper { return `${vscode.env.uriScheme}://${this.extensionId}${CALLBACK_PATH}`; } - private async loadRegistration(): Promise { + private async loadClientRegistration(): Promise { const registration = await this.secretsManager.getOAuthClientRegistration(); if (registration) { this.clientRegistration = registration; @@ -151,7 +141,7 @@ export class CoderOAuthHelper { } } - private async saveRegistration( + private async saveClientRegistration( registration: ClientRegistrationResponse, ): Promise { await this.secretsManager.setOAuthClientRegistration(registration); @@ -191,25 +181,21 @@ export class CoderOAuthHelper { const registrationRequest: ClientRegistrationRequest = { redirect_uris: [redirectUri], application_type: "native", - grant_types: [OAUTH_GRANT_TYPE], - response_types: [OAUTH_RESPONSE_TYPE], + grant_types: [AUTH_GRANT_TYPE], + response_types: [RESPONSE_TYPE], client_name: CLIENT_NAME, - token_endpoint_auth_method: OAUTH_AUTH_METHOD, + token_endpoint_auth_method: OAUTH_METHOD, }; const response = await this.client .getAxiosInstance() - .request({ - url: metadata.registration_endpoint, - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - data: registrationRequest, - }); + .post( + metadata.registration_endpoint, + registrationRequest, + ); + + await this.saveClientRegistration(response.data); - await this.saveRegistration(response.data); return response.data.client_id; } @@ -232,7 +218,7 @@ export class CoderOAuthHelper { const params: AuthorizationRequestParams = { client_id: clientId, - response_type: OAUTH_RESPONSE_TYPE, + response_type: RESPONSE_TYPE, redirect_uri: this.getRedirectUri(), scope, state, @@ -268,12 +254,15 @@ export class CoderOAuthHelper { return new Promise<{ code: string; verifier: string }>( (resolve, reject) => { + const timeoutMins = 5; const timeout = setTimeout( () => { this.clearPendingAuth(); - reject(new Error("OAuth flow timed out after 5 minutes")); + reject( + new Error(`OAuth flow timed out after ${timeoutMins} minutes`), + ); }, - 5 * 60 * 1000, + timeoutMins * 60 * 1000, ); const clearPromise = () => { @@ -366,7 +355,7 @@ export class CoderOAuthHelper { this.logger.info("Exchanging authorization code for token"); const params: TokenRequestParams = { - grant_type: OAUTH_GRANT_TYPE, + grant_type: AUTH_GRANT_TYPE, code, redirect_uri: this.getRedirectUri(), client_id: this.clientRegistration.client_id, @@ -383,14 +372,10 @@ export class CoderOAuthHelper { const response = await this.client .getAxiosInstance() - .request({ - url: metadata.token_endpoint, - method: "POST", + .post(metadata.token_endpoint, tokenRequest.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", }, - data: tokenRequest.toString(), }); this.logger.info("Token exchange successful"); From 01024bd66b1360b65219488bd0033329398bc8de Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 24 Oct 2025 15:16:31 +0300 Subject: [PATCH 05/11] Add token refresh and revocation --- package.json | 9 +- src/core/secretsManager.ts | 46 +++++- src/oauth/oauthHelper.ts | 300 ++++++++++++++++++++++++++++++++++--- 3 files changed, 331 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index f6e96686..147348d1 100644 --- a/package.json +++ b/package.json @@ -257,8 +257,13 @@ "icon": "$(search)" }, { - "command": "coder.oauth.testAuth", - "title": "Test OAuth Auth", + "command": "coder.oauth.login", + "title": "OAuth Login", + "category": "Coder" + }, + { + "command": "coder.oauth.logout", + "title": "OAuth Logout", "category": "Coder" } ], diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 4821b962..b108af0a 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -1,4 +1,7 @@ -import { type ClientRegistrationResponse } from "../oauth/types"; +import { + type TokenResponse, + type ClientRegistrationResponse, +} from "../oauth/types"; import type { SecretStorage, Disposable } from "vscode"; @@ -8,6 +11,12 @@ const LOGIN_STATE_KEY = "loginState"; const OAUTH_CLIENT_REGISTRATION_KEY = "oauthClientRegistration"; +const OAUTH_TOKENS_KEY = "oauthTokens"; + +export type StoredOAuthTokens = Omit & { + expiry_timestamp: number; +}; + export enum AuthAction { LOGIN, LOGOUT, @@ -109,4 +118,39 @@ export class SecretsManager { } return undefined; } + + /** + * Store OAuth token data including expiry timestamp. + */ + public async setOAuthTokens( + tokens: StoredOAuthTokens | undefined, + ): Promise { + if (tokens) { + await this.secrets.store(OAUTH_TOKENS_KEY, JSON.stringify(tokens)); + } else { + await this.secrets.delete(OAUTH_TOKENS_KEY); + } + } + + /** + * Get stored OAuth token data. + */ + public async getOAuthTokens(): Promise { + try { + const stringifiedTokens = await this.secrets.get(OAUTH_TOKENS_KEY); + if (stringifiedTokens) { + return JSON.parse(stringifiedTokens) as StoredOAuthTokens; + } + } catch { + // Do nothing + } + return undefined; + } + + /** + * Clear OAuth token data. + */ + public async clearOAuthTokens(): Promise { + await this.secrets.delete(OAUTH_TOKENS_KEY); + } } diff --git a/src/oauth/oauthHelper.ts b/src/oauth/oauthHelper.ts index bbd0a82c..03154fc7 100644 --- a/src/oauth/oauthHelper.ts +++ b/src/oauth/oauthHelper.ts @@ -1,7 +1,10 @@ import * as vscode from "vscode"; import { type CoderApi } from "../api/coderApi"; -import { type SecretsManager } from "../core/secretsManager"; +import { + type StoredOAuthTokens, + type SecretsManager, +} from "../core/secretsManager"; import { CALLBACK_PATH, generatePKCE, generateState } from "./utils"; @@ -12,8 +15,10 @@ import type { ClientRegistrationRequest, ClientRegistrationResponse, OAuthServerMetadata, + RefreshTokenRequestParams, TokenRequestParams, TokenResponse, + TokenRevocationRequest, } from "./types"; const AUTH_GRANT_TYPE = "authorization_code" as const; @@ -25,6 +30,9 @@ const CLIENT_NAME = "VS Code Coder Extension"; const REQUIRED_GRANT_TYPES = [AUTH_GRANT_TYPE, REFRESH_GRANT_TYPE] as const; +// Token refresh timing constants +const TOKEN_REFRESH_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes before expiry + export class CoderOAuthHelper { private clientRegistration: ClientRegistrationResponse | undefined; private cachedMetadata: OAuthServerMetadata | undefined; @@ -34,6 +42,8 @@ export class CoderOAuthHelper { private pendingAuthReject: ((reason: Error) => void) | undefined; private expectedState: string | undefined; private pendingVerifier: string | undefined; + private storedTokens: StoredOAuthTokens | undefined; + private refreshTimer: NodeJS.Timeout | undefined; private readonly extensionId: string; @@ -50,6 +60,7 @@ export class CoderOAuthHelper { context, ); await helper.loadClientRegistration(); + await helper.loadTokens(); return helper; } private constructor( @@ -120,10 +131,11 @@ export class CoderOAuthHelper { } this.cachedMetadata = metadata; - this.logger.info("OAuth endpoints discovered:", { + this.logger.debug("OAuth endpoints discovered:", { authorization: metadata.authorization_endpoint, token: metadata.token_endpoint, registration: metadata.registration_endpoint, + revocation: metadata.revocation_endpoint, }); return metadata; @@ -141,6 +153,20 @@ export class CoderOAuthHelper { } } + private async loadTokens(): Promise { + const tokens = await this.secretsManager.getOAuthTokens(); + if (tokens) { + this.storedTokens = tokens; + this.logger.info("Loaded stored OAuth tokens", { + expires_at: new Date(tokens.expiry_timestamp).toISOString(), + }); + + if (tokens.refresh_token) { + this.startRefreshTimer(); + } + } + } + private async saveClientRegistration( registration: ClientRegistrationResponse, ): Promise { @@ -178,9 +204,10 @@ export class CoderOAuthHelper { ); } + // "web" type since VS Code Secrets API allows secure client_secret storage (confidential client). const registrationRequest: ClientRegistrationRequest = { redirect_uris: [redirectUri], - application_type: "native", + application_type: "web", grant_types: [AUTH_GRANT_TYPE], response_types: [RESPONSE_TYPE], client_name: CLIENT_NAME, @@ -228,10 +255,11 @@ export class CoderOAuthHelper { const url = `${metadata.authorization_endpoint}?${new URLSearchParams(params as unknown as Record).toString()}`; - this.logger.info("OAuth Authorization URL:", url); - this.logger.info("Client ID:", clientId); - this.logger.info("Redirect URI:", this.getRedirectUri()); - this.logger.info("Scope:", scope); + this.logger.debug("Building OAuth authorization URL:", { + client_id: clientId, + redirect_uri: this.getRedirectUri(), + scope, + }); return url; } @@ -366,25 +394,234 @@ export class CoderOAuthHelper { params.client_secret = this.clientRegistration.client_secret; } - const tokenRequest = new URLSearchParams( - params as unknown as Record, - ); + const tokenRequest = toUrlSearchParams(params); const response = await this.client .getAxiosInstance() - .post(metadata.token_endpoint, tokenRequest.toString(), { + .post(metadata.token_endpoint, tokenRequest, { headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); this.logger.info("Token exchange successful"); + + await this.saveTokens(response.data); + return response.data; } getClientId(): string | undefined { return this.clientRegistration?.client_id; } + + /** + * Refresh the access token using the stored refresh token. + */ + private async refreshToken(): Promise { + if (!this.storedTokens?.refresh_token) { + throw new Error("No refresh token available"); + } + + if (!this.clientRegistration) { + throw new Error("No client registration found"); + } + + const metadata = await this.getMetadata(); + + this.logger.debug("Refreshing access token"); + + const params: RefreshTokenRequestParams = { + grant_type: REFRESH_GRANT_TYPE, + refresh_token: this.storedTokens.refresh_token, + client_id: this.clientRegistration.client_id, + }; + + if (this.clientRegistration.client_secret) { + params.client_secret = this.clientRegistration.client_secret; + } + + const tokenRequest = toUrlSearchParams(params); + + const response = await this.client + .getAxiosInstance() + .post(metadata.token_endpoint, tokenRequest, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + this.logger.debug("Token refresh successful"); + + await this.saveTokens(response.data); + + return response.data; + } + + /** + * Save token response to secrets storage and restart the refresh timer. + */ + private async saveTokens(tokenResponse: TokenResponse): Promise { + const expiryTimestamp = tokenResponse.expires_in + ? Date.now() + tokenResponse.expires_in * 1000 + : Date.now() + 3600 * 1000; // Default to 1 hour if not specified + + const tokens: StoredOAuthTokens = { + ...tokenResponse, + expiry_timestamp: expiryTimestamp, + }; + + this.storedTokens = tokens; + await this.secretsManager.setOAuthTokens(tokens); + + this.logger.info("Tokens saved", { + expires_at: new Date(expiryTimestamp).toISOString(), + }); + + // Restart timer with new expiry (creates self-perpetuating refresh cycle) + this.startRefreshTimer(); + } + + /** + * Start the background token refresh timer. + * Sets a timeout to fire exactly when the token is 5 minutes from expiry. + */ + private startRefreshTimer(): void { + this.stopRefreshTimer(); + + if (!this.storedTokens?.refresh_token) { + this.logger.debug("No refresh token available, skipping timer setup"); + return; + } + + const now = Date.now(); + const timeUntilRefresh = + this.storedTokens.expiry_timestamp - TOKEN_REFRESH_THRESHOLD_MS - now; + + // If token is already expired or expires very soon, refresh immediately + if (timeUntilRefresh <= 0) { + this.logger.info("Token needs immediate refresh"); + this.refreshToken().catch((error) => { + this.logger.error("Immediate token refresh failed:", error); + }); + return; + } + + // Set timeout to fire exactly when token is 5 minutes from expiry + this.refreshTimer = setTimeout(() => { + this.logger.debug("Token refresh timer fired, refreshing token..."); + this.refreshToken().catch((error) => { + this.logger.error("Scheduled token refresh failed:", error); + }); + }, timeUntilRefresh); + + this.logger.debug("Token refresh timer scheduled", { + fires_at: new Date(now + timeUntilRefresh).toISOString(), + fires_in: timeUntilRefresh / 1000, + }); + } + + /** + * Stop the background token refresh timer. + */ + private stopRefreshTimer(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; + this.logger.debug("Background token refresh timer stopped"); + } + } + + /** + * Revoke a token using the OAuth server's revocation endpoint. + */ + private async revokeToken( + token: string, + tokenTypeHint?: "access_token" | "refresh_token", + ): Promise { + if (!this.clientRegistration) { + throw new Error("No client registration found"); + } + + const metadata = await this.getMetadata(); + + if (!metadata.revocation_endpoint) { + this.logger.warn( + "Server does not support token revocation (no revocation_endpoint)", + ); + return; + } + + this.logger.info("Revoking token", { tokenTypeHint }); + + const params: TokenRevocationRequest = { + token, + client_id: this.clientRegistration.client_id, + }; + + if (tokenTypeHint) { + params.token_type_hint = tokenTypeHint; + } + + if (this.clientRegistration.client_secret) { + params.client_secret = this.clientRegistration.client_secret; + } + + const revocationRequest = toUrlSearchParams(params); + + try { + await this.client + .getAxiosInstance() + .post(metadata.revocation_endpoint, revocationRequest, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + this.logger.info("Token revocation successful"); + } catch (error) { + this.logger.error("Token revocation failed:", error); + throw error; + } + } + + /** + * Logout by revoking tokens and clearing all OAuth data. + */ + async logout(): Promise { + this.stopRefreshTimer(); + + // Revoke refresh token (which also invalidates access token per RFC 7009) + if (this.storedTokens?.refresh_token) { + try { + await this.revokeToken( + this.storedTokens.refresh_token, + "refresh_token", + ); + } catch (error) { + this.logger.warn("Token revocation failed during logout:", error); + } + } + + // Clear stored tokens + await this.secretsManager.clearOAuthTokens(); + this.storedTokens = undefined; + + // Clear client registration + await this.clearClientRegistration(); + + // Trigger logout state change for other windows + // await this.secretsManager.triggerLoginStateChange("logout"); + + this.logger.info("Logout complete"); + } + + /** + * Cleanup method to be called when disposing the helper. + */ + dispose(): void { + this.stopRefreshTimer(); + } } function includesAllTypes( @@ -399,6 +636,20 @@ function includesAllTypes( return requiredTypes.every((type) => arr.includes(type)); } +/** + * Converts an object with string properties to Record, + * filtering out undefined values for use with URLSearchParams. + */ +function toUrlSearchParams(obj: object): URLSearchParams { + const params = Object.fromEntries( + Object.entries(obj).filter( + ([, value]) => value !== undefined && typeof value === "string", + ), + ) as Record; + + return new URLSearchParams(params); +} + /** * Activates OAuth support for the Coder extension. * Initializes the OAuth helper and registers the test auth command. @@ -417,33 +668,40 @@ export async function activateCoderOAuth( ); context.subscriptions.push( - vscode.commands.registerCommand("coder.oauth.testAuth", async () => { + vscode.commands.registerCommand("coder.oauth.login", async () => { try { const { code, verifier } = await oauthHelper.startAuthorization(); - logger.info( - "Authorization code received:", - code.substring(0, 8) + "...", - ); const tokenResponse = await oauthHelper.exchangeToken(code, verifier); - vscode.window.showInformationMessage( - `OAuth flow completed! Access token received (expires in ${tokenResponse.expires_in}s)`, - ); logger.info("OAuth flow completed:", { token_type: tokenResponse.token_type, expires_in: tokenResponse.expires_in, scope: tokenResponse.scope, }); + vscode.window.showInformationMessage( + `OAuth flow completed! Access token received (expires in ${tokenResponse.expires_in}s)`, + ); + + // Test API call to verify token works client.setSessionToken(tokenResponse.access_token); - const response = await client.getWorkspaces({ q: "owner:me" }); - logger.info(response.workspaces.map((w) => w.name).toString()); + await client.getWorkspaces({ q: "owner:me" }); } catch (error) { vscode.window.showErrorMessage(`OAuth flow failed: ${error}`); logger.error("OAuth flow failed:", error); } }), + vscode.commands.registerCommand("coder.oauth.logout", async () => { + try { + await oauthHelper.logout(); + vscode.window.showInformationMessage("Successfully logged out"); + logger.info("User logged out via OAuth"); + } catch (error) { + vscode.window.showErrorMessage(`Logout failed: ${error}`); + logger.error("OAuth logout failed:", error); + } + }), ); return oauthHelper; From 0aad64e4ddd32339f1e81c13ab87ffcb69b10b39 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 24 Oct 2025 16:15:05 +0300 Subject: [PATCH 06/11] Add proper scopes --- src/oauth/oauthHelper.ts | 96 ++++++++++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 14 deletions(-) diff --git a/src/oauth/oauthHelper.ts b/src/oauth/oauthHelper.ts index 03154fc7..de369ba8 100644 --- a/src/oauth/oauthHelper.ts +++ b/src/oauth/oauthHelper.ts @@ -30,8 +30,28 @@ const CLIENT_NAME = "VS Code Coder Extension"; const REQUIRED_GRANT_TYPES = [AUTH_GRANT_TYPE, REFRESH_GRANT_TYPE] as const; -// Token refresh timing constants -const TOKEN_REFRESH_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes before expiry +// Token refresh timing constants (5 minutes before expiry) +const TOKEN_REFRESH_THRESHOLD_MS = 5 * 60 * 1000; + +/** + * Minimal scopes required by the VS Code extension: + * - workspace:read: List and read workspace details + * - workspace:update: Update workspace version + * - workspace:start: Start stopped workspaces + * - workspace:ssh: SSH configuration for remote connections + * - workspace:application_connect: Connect to workspace agents/apps + * - template:read: Read templates and versions + * - user:read_personal: Read authenticated user info + */ +const DEFAULT_OAUTH_SCOPES = [ + "workspace:read", + "workspace:update", + "workspace:start", + "workspace:ssh", + "workspace:application_connect", + "template:read", + "user:read_personal", +].join(" "); export class CoderOAuthHelper { private clientRegistration: ClientRegistrationResponse | undefined; @@ -156,9 +176,22 @@ export class CoderOAuthHelper { private async loadTokens(): Promise { const tokens = await this.secretsManager.getOAuthTokens(); if (tokens) { + if (!this.hasRequiredScopes(tokens.scope)) { + this.logger.warn( + "Stored token missing required scopes, clearing tokens", + { + stored_scope: tokens.scope, + required_scopes: DEFAULT_OAUTH_SCOPES, + }, + ); + await this.secretsManager.clearOAuthTokens(); + return; + } + this.storedTokens = tokens; this.logger.info("Loaded stored OAuth tokens", { expires_at: new Date(tokens.expiry_timestamp).toISOString(), + scope: tokens.scope, }); if (tokens.refresh_token) { @@ -167,6 +200,40 @@ export class CoderOAuthHelper { } } + /** + * Check if granted scopes cover all required scopes. + * Supports wildcard scopes like "workspace:*" which grant all "workspace:" prefixed scopes. + */ + private hasRequiredScopes(grantedScope: string | undefined): boolean { + if (!grantedScope) { + return false; + } + + const grantedScopes = new Set(grantedScope.split(" ")); + const requiredScopes = DEFAULT_OAUTH_SCOPES.split(" "); + + for (const required of requiredScopes) { + // Check exact match + if (grantedScopes.has(required)) { + continue; + } + + // Check wildcard match (e.g., "workspace:*" grants "workspace:read") + const colonIndex = required.indexOf(":"); + if (colonIndex !== -1) { + const prefix = required.substring(0, colonIndex); + const wildcard = `${prefix}:*`; + if (grantedScopes.has(wildcard)) { + continue; + } + } + + return false; + } + + return true; + } + private async saveClientRegistration( registration: ClientRegistrationResponse, ): Promise { @@ -231,16 +298,19 @@ export class CoderOAuthHelper { clientId: string, state: string, challenge: string, - scope = "all", + scope: string, ): string { - if ( - metadata.scopes_supported && - !metadata.scopes_supported.includes(scope) - ) { - this.logger.warn( - `Requested scope "${scope}" not in server's supported scopes. Server may still accept it.`, - { supported_scopes: metadata.scopes_supported }, + if (metadata.scopes_supported) { + const requestedScopes = scope.split(" "); + const unsupportedScopes = requestedScopes.filter( + (s) => !metadata.scopes_supported?.includes(s), ); + if (unsupportedScopes.length > 0) { + this.logger.warn( + `Requested scopes not in server's supported scopes: ${unsupportedScopes.join(", ")}. Server may still accept them.`, + { supported_scopes: metadata.scopes_supported }, + ); + } } const params: AuthorizationRequestParams = { @@ -264,9 +334,7 @@ export class CoderOAuthHelper { return url; } - async startAuthorization( - scope = "all", - ): Promise<{ code: string; verifier: string }> { + async startAuthorization(): Promise<{ code: string; verifier: string }> { const metadata = await this.getMetadata(); const clientId = await this.registerClient(); const state = generateState(); @@ -277,7 +345,7 @@ export class CoderOAuthHelper { clientId, state, challenge, - scope, + DEFAULT_OAUTH_SCOPES, ); return new Promise<{ code: string; verifier: string }>( From 0e8453bdeaa448e0292f9a85ce280038b7263679 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Sat, 25 Oct 2025 00:21:54 +0300 Subject: [PATCH 07/11] Hook up into the login/logout logic of the extension --- package.json | 10 -- src/commands.ts | 215 +++++++++++++++++++++++++++++++------ src/core/secretsManager.ts | 14 +++ src/extension.ts | 27 ++++- src/oauth/oauthHelper.ts | 95 ++++++---------- 5 files changed, 252 insertions(+), 109 deletions(-) diff --git a/package.json b/package.json index 147348d1..49844183 100644 --- a/package.json +++ b/package.json @@ -255,16 +255,6 @@ "title": "Search", "category": "Coder", "icon": "$(search)" - }, - { - "command": "coder.oauth.login", - "title": "OAuth Login", - "category": "Coder" - }, - { - "command": "coder.oauth.logout", - "title": "OAuth Logout", - "category": "Coder" } ], "menus": { diff --git a/src/commands.ts b/src/commands.ts index 5abeb026..20cad4cf 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,6 +19,7 @@ import { type SecretsManager } from "./core/secretsManager"; import { CertificateError } from "./error"; import { getGlobalFlags } from "./globalFlags"; import { type Logger } from "./logging/logger"; +import { type CoderOAuthHelper } from "./oauth/oauthHelper"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; import { AgentTreeItem, @@ -48,6 +49,7 @@ export class Commands { public constructor( serviceContainer: ServiceContainer, private readonly restClient: Api, + private readonly oauthHelper: CoderOAuthHelper, ) { this.vscodeProposed = serviceContainer.getVsCodeProposed(); this.logger = serviceContainer.getLogger(); @@ -182,59 +184,119 @@ export class Commands { } /** - * Log into the provided deployment. If the deployment URL is not specified, - * ask for it first with a menu showing recent URLs along with the default URL - * and CODER_URL, if those are set. + * Check if server supports OAuth by attempting to fetch the well-known endpoint. */ - public async login(args?: { - url?: string; - token?: string; - label?: string; - autoLogin?: boolean; - }): Promise { - if (this.contextManager.get("coder.authenticated")) { - return; + private async checkOAuthSupport(client: CoderApi): Promise { + try { + await client + .getAxiosInstance() + .get("/.well-known/oauth-authorization-server"); + this.logger.debug("Server supports OAuth"); + return true; + } catch (error) { + this.logger.debug("Server does not support OAuth:", error); + return false; } - this.logger.info("Logging in"); + } - const url = await this.maybeAskUrl(args?.url); - if (!url) { - return; // The user aborted. - } + /** + * Ask user to choose between OAuth and legacy API token authentication. + */ + private async askAuthMethod(): Promise<"oauth" | "legacy" | undefined> { + const choice = await vscode.window.showQuickPick( + [ + { + label: "$(key) OAuth (Recommended)", + detail: "Secure authentication with automatic token refresh", + value: "oauth", + }, + { + label: "$(lock) API Token", + detail: "Use a manually created API key", + value: "legacy", + }, + ], + { + title: "Choose Authentication Method", + placeHolder: "How would you like to authenticate?", + ignoreFocusOut: true, + }, + ); - // It is possible that we are trying to log into an old-style host, in which - // case we want to write with the provided blank label instead of generating - // a host label. - const label = args?.label === undefined ? toSafeHost(url) : args.label; + return choice?.value as "oauth" | "legacy" | undefined; + } - // Try to get a token from the user, if we need one, and their user. - const autoLogin = args?.autoLogin === true; - const res = await this.maybeAskToken(url, args?.token, autoLogin); - if (!res) { - return; // The user aborted, or unable to auth. + /** + * Authenticate using OAuth flow. + * Returns the access token and authenticated user, or null if failed/cancelled. + */ + private async loginWithOAuth( + url: string, + ): Promise<{ user: User; token: string } | null> { + try { + this.logger.info("Starting OAuth authentication"); + + // Start OAuth authorization flow + const { code, verifier } = await this.oauthHelper.startAuthorization(url); + + // Exchange authorization code for tokens + const tokenResponse = await this.oauthHelper.exchangeToken( + code, + verifier, + ); + + // Validate token by fetching user + const client = CoderApi.create( + url, + tokenResponse.access_token, + this.logger, + ); + const user = await client.getAuthenticatedUser(); + + this.logger.info("OAuth authentication successful"); + + return { + token: tokenResponse.access_token, + user, + }; + } catch (error) { + this.logger.error("OAuth authentication failed:", error); + vscode.window.showErrorMessage( + `OAuth authentication failed: ${getErrorMessage(error, "Unknown error")}`, + ); + return null; } + } - // The URL is good and the token is either good or not required; authorize - // the global client. + /** + * Complete the login process by storing credentials and updating context. + */ + private async completeLogin( + url: string, + label: string, + token: string, + user: User, + ): Promise { + // Authorize the global client this.restClient.setHost(url); - this.restClient.setSessionToken(res.token); + this.restClient.setSessionToken(token); - // Store these to be used in later sessions. + // Store for later sessions await this.mementoManager.setUrl(url); - await this.secretsManager.setSessionToken(res.token); + await this.secretsManager.setSessionToken(token); - // Store on disk to be used by the cli. - await this.cliManager.configure(label, url, res.token); + // Store on disk for CLI + await this.cliManager.configure(label, url, token); - // These contexts control various menu items and the sidebar. + // Update contexts this.contextManager.set("coder.authenticated", true); - if (res.user.roles.find((role) => role.name === "owner")) { + if (user.roles.find((role) => role.name === "owner")) { this.contextManager.set("coder.isOwner", true); } vscode.window .showInformationMessage( - `Welcome to Coder, ${res.user.username}!`, + `Welcome to Coder, ${user.username}!`, { detail: "You can now use the Coder extension to manage your Coder instance.", @@ -252,6 +314,73 @@ export class Commands { vscode.commands.executeCommand("coder.refreshWorkspaces"); } + /** + * Log into the provided deployment. If the deployment URL is not specified, + * ask for it first with a menu showing recent URLs along with the default URL + * and CODER_URL, if those are set. + */ + public async login(args?: { + url?: string; + token?: string; + label?: string; + autoLogin?: boolean; + }): Promise { + if (this.contextManager.get("coder.authenticated")) { + return; + } + this.logger.info("Logging in"); + + const url = await this.maybeAskUrl(args?.url); + if (!url) { + return; // The user aborted. + } + + const label = args?.label ?? toSafeHost(url); + const autoLogin = args?.autoLogin === true; + + // Check if we have an existing valid legacy token + const existingToken = await this.secretsManager.getSessionToken(); + const client = CoderApi.create(url, existingToken, this.logger); + if (existingToken && !args?.token) { + try { + const user = await client.getAuthenticatedUser(); + this.logger.info("Using existing valid session token"); + await this.completeLogin(url, label, existingToken, user); + return; + } catch { + this.logger.debug("Existing token invalid, clearing it"); + await this.secretsManager.setSessionToken(); + } + } + + // Check if server supports OAuth + const supportsOAuth = await this.checkOAuthSupport(client); + + if (supportsOAuth && !autoLogin) { + const choice = await this.askAuthMethod(); + if (!choice) { + return; + } + + if (choice === "oauth") { + const res = await this.loginWithOAuth(url); + if (!res) { + return; + } + await this.completeLogin(url, label, res.token, res.user); + return; + } + } + + // Use legacy token flow (existing behavior) + const res = await this.maybeAskToken(url, args?.token, autoLogin); + if (!res) { + return; + } + + await this.completeLogin(url, label, res.token, res.user); + } + /** * If necessary, ask for a token, and keep asking until the token has been * validated. Return the token and user that was fetched to validate the @@ -377,6 +506,22 @@ export class Commands { // Sanity check; command should not be available if no url. throw new Error("You are not logged in"); } + + // Check if using OAuth + const hasOAuthTokens = await this.secretsManager.getOAuthTokens(); + if (hasOAuthTokens) { + this.logger.info("Logging out via OAuth"); + try { + await this.oauthHelper.logout(); + } catch (error) { + this.logger.warn( + "OAuth logout failed, continuing with cleanup:", + error, + ); + } + } + + // Continue with standard logout (clears sessionToken, contexts, etc) await this.forceLogout(); } diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index b108af0a..02681132 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -84,6 +84,20 @@ export class SecretsManager { }); } + /** + * Listens for session token changes. + */ + public onDidChangeSessionToken( + listener: (token: string | undefined) => Promise, + ): Disposable { + return this.secrets.onDidChange(async (e) => { + if (e.key === SESSION_TOKEN_KEY) { + const token = await this.getSessionToken(); + await listener(token); + } + }); + } + /** * Store OAuth client registration data. */ diff --git a/src/extension.ts b/src/extension.ts index b6285708..3ffc3136 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -124,11 +124,34 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); const oauthHelper = await activateCoderOAuth( - client, + url || "", secretsManager, output, ctx, ); + ctx.subscriptions.push(oauthHelper); + + // Listen for session token changes and sync state across all components + ctx.subscriptions.push( + secretsManager.onDidChangeSessionToken(async (token) => { + if (!token) { + output.debug("Session token cleared"); + client.setSessionToken(""); + return; + } + + output.debug("Session token changed, syncing state"); + + client.setSessionToken(token); + const url = mementoManager.getUrl(); + if (url) { + const cliManager = serviceContainer.getCliManager(); + // TODO label might not match? + await cliManager.configure(toSafeHost(url), url, token); + output.debug("Updated CLI config with new token"); + } + }), + ); // Handle vscode:// URIs. const uriHandler = vscode.window.registerUriHandler({ @@ -293,7 +316,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. - const commands = new Commands(serviceContainer, client); + const commands = new Commands(serviceContainer, client, oauthHelper); ctx.subscriptions.push( vscode.commands.registerCommand( "coder.login", diff --git a/src/oauth/oauthHelper.ts b/src/oauth/oauthHelper.ts index de369ba8..efdc9b8b 100644 --- a/src/oauth/oauthHelper.ts +++ b/src/oauth/oauthHelper.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { type CoderApi } from "../api/coderApi"; +import { CoderApi } from "../api/coderApi"; import { type StoredOAuthTokens, type SecretsManager, @@ -53,7 +53,9 @@ const DEFAULT_OAUTH_SCOPES = [ "user:read_personal", ].join(" "); -export class CoderOAuthHelper { +export class CoderOAuthHelper implements vscode.Disposable { + private readonly client: CoderApi; + private clientRegistration: ClientRegistrationResponse | undefined; private cachedMetadata: OAuthServerMetadata | undefined; private pendingAuthResolve: @@ -68,13 +70,13 @@ export class CoderOAuthHelper { private readonly extensionId: string; static async create( - client: CoderApi, + baseUrl: string, secretsManager: SecretsManager, logger: Logger, context: vscode.ExtensionContext, ): Promise { const helper = new CoderOAuthHelper( - client, + baseUrl, secretsManager, logger, context, @@ -84,11 +86,12 @@ export class CoderOAuthHelper { return helper; } private constructor( - private readonly client: CoderApi, + baseUrl: string, private readonly secretsManager: SecretsManager, private readonly logger: Logger, context: vscode.ExtensionContext, ) { + this.client = CoderApi.create(baseUrl, undefined, logger); this.extensionId = context.extension.id; } @@ -193,6 +196,7 @@ export class CoderOAuthHelper { expires_at: new Date(tokens.expiry_timestamp).toISOString(), scope: tokens.scope, }); + this.client.setSessionToken(tokens.access_token); if (tokens.refresh_token) { this.startRefreshTimer(); @@ -334,7 +338,11 @@ export class CoderOAuthHelper { return url; } - async startAuthorization(): Promise<{ code: string; verifier: string }> { + async startAuthorization( + url: string, + ): Promise<{ code: string; verifier: string }> { + this.client.setHost(url); + const metadata = await this.getMetadata(); const clientId = await this.registerClient(); const state = generateState(); @@ -541,6 +549,8 @@ export class CoderOAuthHelper { this.storedTokens = tokens; await this.secretsManager.setOAuthTokens(tokens); + await this.secretsManager.setSessionToken(tokenResponse.access_token); + this.client.setSessionToken(tokens.access_token); this.logger.info("Tokens saved", { expires_at: new Date(expiryTimestamp).toISOString(), @@ -552,7 +562,7 @@ export class CoderOAuthHelper { /** * Start the background token refresh timer. - * Sets a timeout to fire exactly when the token is 5 minutes from expiry. + * Sets a timeout to fire when the token is close to expiry. */ private startRefreshTimer(): void { this.stopRefreshTimer(); @@ -671,24 +681,29 @@ export class CoderOAuthHelper { } } - // Clear stored tokens await this.secretsManager.clearOAuthTokens(); this.storedTokens = undefined; - - // Clear client registration await this.clearClientRegistration(); - // Trigger logout state change for other windows - // await this.secretsManager.triggerLoginStateChange("logout"); - - this.logger.info("Logout complete"); + this.logger.info("OAuth logout complete"); } /** - * Cleanup method to be called when disposing the helper. + * Clears all in-memory state and rejects any pending operations. */ dispose(): void { this.stopRefreshTimer(); + + if (this.pendingAuthReject) { + this.pendingAuthReject(new Error("OAuth helper disposed")); + } + this.clearPendingAuth(); + + this.storedTokens = undefined; + this.clientRegistration = undefined; + this.cachedMetadata = undefined; + + this.logger.debug("OAuth helper disposed, all state cleared"); } } @@ -720,57 +735,13 @@ function toUrlSearchParams(obj: object): URLSearchParams { /** * Activates OAuth support for the Coder extension. - * Initializes the OAuth helper and registers the test auth command. + * Initializes and returns the OAuth helper. */ export async function activateCoderOAuth( - client: CoderApi, + baseUrl: string, secretsManager: SecretsManager, logger: Logger, context: vscode.ExtensionContext, ): Promise { - const oauthHelper = await CoderOAuthHelper.create( - client, - secretsManager, - logger, - context, - ); - - context.subscriptions.push( - vscode.commands.registerCommand("coder.oauth.login", async () => { - try { - const { code, verifier } = await oauthHelper.startAuthorization(); - - const tokenResponse = await oauthHelper.exchangeToken(code, verifier); - - logger.info("OAuth flow completed:", { - token_type: tokenResponse.token_type, - expires_in: tokenResponse.expires_in, - scope: tokenResponse.scope, - }); - - vscode.window.showInformationMessage( - `OAuth flow completed! Access token received (expires in ${tokenResponse.expires_in}s)`, - ); - - // Test API call to verify token works - client.setSessionToken(tokenResponse.access_token); - await client.getWorkspaces({ q: "owner:me" }); - } catch (error) { - vscode.window.showErrorMessage(`OAuth flow failed: ${error}`); - logger.error("OAuth flow failed:", error); - } - }), - vscode.commands.registerCommand("coder.oauth.logout", async () => { - try { - await oauthHelper.logout(); - vscode.window.showInformationMessage("Successfully logged out"); - logger.info("User logged out via OAuth"); - } catch (error) { - vscode.window.showErrorMessage(`Logout failed: ${error}`); - logger.error("OAuth logout failed:", error); - } - }), - ); - - return oauthHelper; + return CoderOAuthHelper.create(baseUrl, secretsManager, logger, context); } From cb9ab6a4c4863b0196c4e730eb2dfaed03dded01 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 27 Oct 2025 12:12:35 +0300 Subject: [PATCH 08/11] WIP refactoring --- src/commands.ts | 319 ++++++------ src/core/mementoManager.ts | 2 +- src/core/secretsManager.ts | 12 +- src/extension.ts | 10 +- src/oauth/clientRegistry.ts | 111 +++++ src/oauth/metadataClient.ts | 137 ++++++ src/oauth/oauthHelper.ts | 747 ----------------------------- src/oauth/sessionManager.ts | 636 ++++++++++++++++++++++++ src/oauth/tokenRefreshScheduler.ts | 65 +++ src/oauth/utils.ts | 14 + src/remote/remote.ts | 10 +- 11 files changed, 1124 insertions(+), 939 deletions(-) create mode 100644 src/oauth/clientRegistry.ts create mode 100644 src/oauth/metadataClient.ts delete mode 100644 src/oauth/oauthHelper.ts create mode 100644 src/oauth/sessionManager.ts create mode 100644 src/oauth/tokenRefreshScheduler.ts diff --git a/src/commands.ts b/src/commands.ts index 20cad4cf..a31a8b2f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,7 +19,8 @@ import { type SecretsManager } from "./core/secretsManager"; import { CertificateError } from "./error"; import { getGlobalFlags } from "./globalFlags"; import { type Logger } from "./logging/logger"; -import { type CoderOAuthHelper } from "./oauth/oauthHelper"; +import { OAuthMetadataClient } from "./oauth/metadataClient"; +import { type OAuthSessionManager } from "./oauth/sessionManager"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; import { AgentTreeItem, @@ -27,6 +28,8 @@ import { WorkspaceTreeItem, } from "./workspace/workspacesProvider"; +type AuthMethod = "oauth" | "legacy"; + export class Commands { private readonly vscodeProposed: typeof vscode; private readonly logger: Logger; @@ -49,7 +52,7 @@ export class Commands { public constructor( serviceContainer: ServiceContainer, private readonly restClient: Api, - private readonly oauthHelper: CoderOAuthHelper, + private readonly oauthSessionManager: OAuthSessionManager, ) { this.vscodeProposed = serviceContainer.getVsCodeProposed(); this.logger = serviceContainer.getLogger(); @@ -184,119 +187,59 @@ export class Commands { } /** - * Check if server supports OAuth by attempting to fetch the well-known endpoint. + * Log into the provided deployment. If the deployment URL is not specified, + * ask for it first with a menu showing recent URLs along with the default URL + * and CODER_URL, if those are set. */ - private async checkOAuthSupport(client: CoderApi): Promise { - try { - await client - .getAxiosInstance() - .get("/.well-known/oauth-authorization-server"); - this.logger.debug("Server supports OAuth"); - return true; - } catch (error) { - this.logger.debug("Server does not support OAuth:", error); - return false; + public async login(args?: { + url?: string; + token?: string; + label?: string; + autoLogin?: boolean; + }): Promise { + if (this.contextManager.get("coder.authenticated")) { + return; } - } - - /** - * Ask user to choose between OAuth and legacy API token authentication. - */ - private async askAuthMethod(): Promise<"oauth" | "legacy" | undefined> { - const choice = await vscode.window.showQuickPick( - [ - { - label: "$(key) OAuth (Recommended)", - detail: "Secure authentication with automatic token refresh", - value: "oauth", - }, - { - label: "$(lock) API Token", - detail: "Use a manually created API key", - value: "legacy", - }, - ], - { - title: "Choose Authentication Method", - placeHolder: "How would you like to authenticate?", - ignoreFocusOut: true, - }, - ); - - return choice?.value as "oauth" | "legacy" | undefined; - } - - /** - * Authenticate using OAuth flow. - * Returns the access token and authenticated user, or null if failed/cancelled. - */ - private async loginWithOAuth( - url: string, - ): Promise<{ user: User; token: string } | null> { - try { - this.logger.info("Starting OAuth authentication"); - - // Start OAuth authorization flow - const { code, verifier } = await this.oauthHelper.startAuthorization(url); - - // Exchange authorization code for tokens - const tokenResponse = await this.oauthHelper.exchangeToken( - code, - verifier, - ); + this.logger.info("Logging in"); - // Validate token by fetching user - const client = CoderApi.create( - url, - tokenResponse.access_token, - this.logger, - ); - const user = await client.getAuthenticatedUser(); + const url = await this.maybeAskUrl(args?.url); + if (!url) { + return; // The user aborted. + } - this.logger.info("OAuth authentication successful"); + // It is possible that we are trying to log into an old-style host, in which + // case we want to write with the provided blank label instead of generating + // a host label. + const label = args?.label ?? toSafeHost(url); + // Try to get a token from the user, if we need one, and their user. + const autoLogin = args?.autoLogin === true; - return { - token: tokenResponse.access_token, - user, - }; - } catch (error) { - this.logger.error("OAuth authentication failed:", error); - vscode.window.showErrorMessage( - `OAuth authentication failed: ${getErrorMessage(error, "Unknown error")}`, - ); - return null; + const res = await this.maybeAskToken(url, args?.token, autoLogin); + if (!res) { + return; // The user aborted, or unable to auth. } - } - /** - * Complete the login process by storing credentials and updating context. - */ - private async completeLogin( - url: string, - label: string, - token: string, - user: User, - ): Promise { - // Authorize the global client + // The URL is good and the token is either good or not required; authorize + // the global client. this.restClient.setHost(url); - this.restClient.setSessionToken(token); + this.restClient.setSessionToken(res.token); - // Store for later sessions + // Store these to be used in later sessions. await this.mementoManager.setUrl(url); - await this.secretsManager.setSessionToken(token); + await this.secretsManager.setSessionToken(res.token); - // Store on disk for CLI - await this.cliManager.configure(label, url, token); + // Store on disk to be used by the cli. + await this.cliManager.configure(label, url, res.token); - // Update contexts + // These contexts control various menu items and the sidebar. this.contextManager.set("coder.authenticated", true); - if (user.roles.find((role) => role.name === "owner")) { + if (res.user.roles.some((role) => role.name === "owner")) { this.contextManager.set("coder.isOwner", true); } vscode.window .showInformationMessage( - `Welcome to Coder, ${user.username}!`, + `Welcome to Coder, ${res.user.username}!`, { detail: "You can now use the Coder extension to manage your Coder instance.", @@ -314,73 +257,6 @@ export class Commands { vscode.commands.executeCommand("coder.refreshWorkspaces"); } - /** - * Log into the provided deployment. If the deployment URL is not specified, - * ask for it first with a menu showing recent URLs along with the default URL - * and CODER_URL, if those are set. - */ - public async login(args?: { - url?: string; - token?: string; - label?: string; - autoLogin?: boolean; - }): Promise { - if (this.contextManager.get("coder.authenticated")) { - return; - } - this.logger.info("Logging in"); - - const url = await this.maybeAskUrl(args?.url); - if (!url) { - return; // The user aborted. - } - - const label = args?.label ?? toSafeHost(url); - const autoLogin = args?.autoLogin === true; - - // Check if we have an existing valid legacy token - const existingToken = await this.secretsManager.getSessionToken(); - const client = CoderApi.create(url, existingToken, this.logger); - if (existingToken && !args?.token) { - try { - const user = await client.getAuthenticatedUser(); - this.logger.info("Using existing valid session token"); - await this.completeLogin(url, label, existingToken, user); - return; - } catch { - this.logger.debug("Existing token invalid, clearing it"); - await this.secretsManager.setSessionToken(); - } - } - - // Check if server supports OAuth - const supportsOAuth = await this.checkOAuthSupport(client); - - if (supportsOAuth && !autoLogin) { - const choice = await this.askAuthMethod(); - if (!choice) { - return; - } - - if (choice === "oauth") { - const res = await this.loginWithOAuth(url); - if (!res) { - return; - } - await this.completeLogin(url, label, res.token, res.user); - return; - } - } - - // Use legacy token flow (existing behavior) - const res = await this.maybeAskToken(url, args?.token, autoLogin); - if (!res) { - return; - } - - await this.completeLogin(url, label, res.token, res.user); - } - /** * If necessary, ask for a token, and keep asking until the token has been * validated. Return the token and user that was fetched to validate the @@ -420,6 +296,64 @@ export class Commands { } } + // Check if server supports OAuth + const supportsOAuth = await this.checkOAuthSupport(client); + + let choice: AuthMethod | undefined = "legacy"; + if (supportsOAuth) { + choice = await this.askAuthMethod(); + } + + if (choice === "oauth") { + return this.loginWithOAuth(url, client); + } else if (choice === "legacy") { + return this.loginWithToken(url, token, client); + } + + // User aborted. + return null; + } + + private async checkOAuthSupport(client: CoderApi): Promise { + const metadataClient = new OAuthMetadataClient( + client.getAxiosInstance(), + this.logger, + ); + return metadataClient.checkOAuthSupport(); + } + + /** + * Ask user to choose between OAuth and legacy API token authentication. + */ + private async askAuthMethod(): Promise { + const choice = await vscode.window.showQuickPick( + [ + { + label: "$(key) OAuth (Recommended)", + detail: "Secure authentication with automatic token refresh", + value: "oauth" as const, + }, + { + label: "$(lock) API Token", + detail: "Use a manually created API key", + value: "legacy" as const, + }, + ], + { + title: "Choose Authentication Method", + placeHolder: "How would you like to authenticate?", + ignoreFocusOut: true, + }, + ); + + return choice?.value; + } + + private async loginWithToken( + url: string, + token: string | undefined, + client: CoderApi, + ): Promise<{ user: User; token: string } | null> { // This prompt is for convenience; do not error if they close it since // they may already have a token or already have the page opened. await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`)); @@ -464,12 +398,52 @@ export class Commands { }, }); - if (validatedToken && user) { - return { token: validatedToken, user }; + if (user === undefined || validatedToken === undefined) { + return null; } - // User aborted. - return null; + return { user, token: validatedToken }; + } + + /** + * Authenticate using OAuth flow. + * Returns the access token and authenticated user, or null if failed/cancelled. + */ + private async loginWithOAuth( + url: string, + client: CoderApi, + ): Promise<{ user: User; token: string } | null> { + try { + this.logger.info("Starting OAuth authentication"); + + // Start OAuth authorization flow + // TODO just pass the client here and do all the neccessary steps (If we are already logged in we'd have the right token and the OAuth client registration saved). + const { code, verifier } = + await this.oauthSessionManager.startAuthorization(url); + + // Exchange authorization code for tokens + const tokenResponse = await this.oauthSessionManager.exchangeToken( + code, + verifier, + ); + + // Validate token by fetching user + client.setSessionToken(tokenResponse.access_token); + const user = await client.getAuthenticatedUser(); + + this.logger.info("OAuth authentication successful"); + + return { + token: tokenResponse.access_token, + user, + }; + } catch (error) { + this.logger.error("OAuth authentication failed:", error); + vscode.window.showErrorMessage( + `OAuth authentication failed: ${getErrorMessage(error, "Unknown error")}`, + ); + return null; + } } /** @@ -512,7 +486,7 @@ export class Commands { if (hasOAuthTokens) { this.logger.info("Logging out via OAuth"); try { - await this.oauthHelper.logout(); + await this.oauthSessionManager.logout(); } catch (error) { this.logger.warn( "OAuth logout failed, continuing with cleanup:", @@ -646,7 +620,7 @@ export class Commands { true, ); } else { - throw new Error("Unable to open unknown sidebar item"); + throw new TypeError("Unable to open unknown sidebar item"); } } else { // If there is no tree item, then the user manually ran this command. @@ -692,7 +666,7 @@ export class Commands { configDir, ); terminal.sendText( - `${escapeCommandArg(binary)}${` ${globalFlags.join(" ")}`} ssh ${app.workspace_name}`, + `${escapeCommandArg(binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`, ); await new Promise((resolve) => setTimeout(resolve, 5000)); terminal.sendText(app.command ?? ""); @@ -791,7 +765,7 @@ export class Commands { workspaceAgent, ); - const hostPath = localWorkspaceFolder ? localWorkspaceFolder : undefined; + const hostPath = localWorkspaceFolder || undefined; const configFile = hostPath && localConfigFile ? { @@ -893,7 +867,6 @@ export class Commands { if (ex instanceof CertificateError) { ex.showNotification(); } - return; }); }); quickPick.show(); diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts index f79be46c..a317ffe5 100644 --- a/src/core/mementoManager.ts +++ b/src/core/mementoManager.ts @@ -13,7 +13,7 @@ export class MementoManager { * If the URL is falsey, then remove it as the last used URL and do not touch * the history. */ - public async setUrl(url?: string): Promise { + public async setUrl(url: string | undefined): Promise { await this.memento.update("url", url); if (url) { const history = this.withUrlHistory(url); diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 02681132..d16292f1 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -15,6 +15,7 @@ const OAUTH_TOKENS_KEY = "oauthTokens"; export type StoredOAuthTokens = Omit & { expiry_timestamp: number; + deployment_url: string; }; export enum AuthAction { @@ -29,7 +30,9 @@ export class SecretsManager { /** * Set or unset the last used token. */ - public async setSessionToken(sessionToken?: string): Promise { + public async setSessionToken( + sessionToken: string | undefined, + ): Promise { if (sessionToken) { await this.secrets.store(SESSION_TOKEN_KEY, sessionToken); } else { @@ -160,11 +163,4 @@ export class SecretsManager { } return undefined; } - - /** - * Clear OAuth token data. - */ - public async clearOAuthTokens(): Promise { - await this.secrets.delete(OAUTH_TOKENS_KEY); - } } diff --git a/src/extension.ts b/src/extension.ts index 3ffc3136..d4676abd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,7 +12,7 @@ import { Commands } from "./commands"; import { ServiceContainer } from "./core/container"; import { AuthAction } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; -import { activateCoderOAuth } from "./oauth/oauthHelper"; +import { OAuthSessionManager } from "./oauth/sessionManager"; import { CALLBACK_PATH } from "./oauth/utils"; import { Remote } from "./remote/remote"; import { toSafeHost } from "./util"; @@ -123,13 +123,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.subscriptions, ); - const oauthHelper = await activateCoderOAuth( + const oauthSessionManager = await OAuthSessionManager.create( url || "", secretsManager, output, ctx, ); - ctx.subscriptions.push(oauthHelper); + ctx.subscriptions.push(oauthSessionManager); // Listen for session token changes and sync state across all components ctx.subscriptions.push( @@ -162,7 +162,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const code = params.get("code"); const state = params.get("state"); const error = params.get("error"); - oauthHelper.handleCallback(code, state, error); + oauthSessionManager.handleCallback(code, state, error); return; } @@ -316,7 +316,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. - const commands = new Commands(serviceContainer, client, oauthHelper); + const commands = new Commands(serviceContainer, client, oauthSessionManager); ctx.subscriptions.push( vscode.commands.registerCommand( "coder.login", diff --git a/src/oauth/clientRegistry.ts b/src/oauth/clientRegistry.ts new file mode 100644 index 00000000..91b4949f --- /dev/null +++ b/src/oauth/clientRegistry.ts @@ -0,0 +1,111 @@ +import type { AxiosInstance } from "axios"; + +import type { SecretsManager } from "../core/secretsManager"; +import type { Logger } from "../logging/logger"; + +import type { + ClientRegistrationRequest, + ClientRegistrationResponse, + OAuthServerMetadata, +} from "./types"; + +const AUTH_GRANT_TYPE = "authorization_code" as const; +const RESPONSE_TYPE = "code" as const; +const OAUTH_METHOD = "client_secret_post" as const; +const CLIENT_NAME = "VS Code Coder Extension"; + +/** + * Manages OAuth client registration and persistence. + */ +export class OAuthClientRegistry { + private registration: ClientRegistrationResponse | undefined; + + constructor( + private readonly axiosInstance: AxiosInstance, + private readonly secretsManager: SecretsManager, + private readonly logger: Logger, + ) {} + + /** + * Load existing client registration from secure storage. + * Should be called during initialization. + */ + async load(): Promise { + const registration = await this.secretsManager.getOAuthClientRegistration(); + if (registration) { + this.registration = registration; + this.logger.info("Loaded existing OAuth client:", registration.client_id); + } + } + + /** + * Get the current client registration if one exists. + */ + get(): ClientRegistrationResponse | undefined { + return this.registration; + } + + /** + * Register a new OAuth client or return existing if still valid. + * Re-registers if redirect URI has changed. + */ + async register( + metadata: OAuthServerMetadata, + redirectUri: string, + ): Promise { + if (this.registration?.client_id) { + if (this.registration.redirect_uris.includes(redirectUri)) { + this.logger.info( + "Using existing client registration:", + this.registration.client_id, + ); + return this.registration; + } + this.logger.info("Redirect URI changed, re-registering client"); + } + + if (!metadata.registration_endpoint) { + throw new Error("Server does not support dynamic client registration"); + } + + // "web" type since VS Code Secrets API allows secure client_secret storage (confidential client) + const registrationRequest: ClientRegistrationRequest = { + redirect_uris: [redirectUri], + application_type: "web", + grant_types: [AUTH_GRANT_TYPE], + response_types: [RESPONSE_TYPE], + client_name: CLIENT_NAME, + token_endpoint_auth_method: OAUTH_METHOD, + }; + + const response = await this.axiosInstance.post( + metadata.registration_endpoint, + registrationRequest, + ); + + await this.save(response.data); + + return response.data; + } + + /** + * Save client registration to secure storage. + */ + private async save(registration: ClientRegistrationResponse): Promise { + await this.secretsManager.setOAuthClientRegistration(registration); + this.registration = registration; + this.logger.info( + "Saved OAuth client registration:", + registration.client_id, + ); + } + + /** + * Clear the current client registration from memory and storage. + */ + async clear(): Promise { + await this.secretsManager.setOAuthClientRegistration(undefined); + this.registration = undefined; + this.logger.info("Cleared OAuth client registration"); + } +} diff --git a/src/oauth/metadataClient.ts b/src/oauth/metadataClient.ts new file mode 100644 index 00000000..7f3227dc --- /dev/null +++ b/src/oauth/metadataClient.ts @@ -0,0 +1,137 @@ +import type { AxiosInstance } from "axios"; + +import type { Logger } from "../logging/logger"; + +import type { OAuthServerMetadata } from "./types"; + +const OAUTH_DISCOVERY_ENDPOINT = "/.well-known/oauth-authorization-server"; + +const AUTH_GRANT_TYPE = "authorization_code" as const; +const REFRESH_GRANT_TYPE = "refresh_token" as const; +const RESPONSE_TYPE = "code" as const; +const OAUTH_METHOD = "client_secret_post" as const; +const PKCE_CHALLENGE_METHOD = "S256" as const; + +const REQUIRED_GRANT_TYPES = [AUTH_GRANT_TYPE, REFRESH_GRANT_TYPE] as const; + +/** + * Client for discovering and validating OAuth server metadata. + */ +export class OAuthMetadataClient { + constructor( + private readonly axiosInstance: AxiosInstance, + private readonly logger: Logger, + ) {} + + /** + * Check if a server supports OAuth by attempting to fetch the well-known endpoint. + */ + async checkOAuthSupport(): Promise { + try { + await this.axiosInstance.get(OAUTH_DISCOVERY_ENDPOINT); + this.logger.debug("Server supports OAuth"); + return true; + } catch (error) { + this.logger.debug("Server does not support OAuth:", error); + return false; + } + } + + /** + * Fetch and validate OAuth server metadata. + * Throws detailed errors if server doesn't meet OAuth 2.1 requirements. + */ + async getMetadata(): Promise { + this.logger.info("Discovering OAuth endpoints..."); + + const response = await this.axiosInstance.get( + OAUTH_DISCOVERY_ENDPOINT, + ); + + const metadata = response.data; + + this.validateRequiredEndpoints(metadata); + this.validateGrantTypes(metadata); + this.validateResponseTypes(metadata); + this.validateAuthMethods(metadata); + this.validatePKCEMethods(metadata); + + this.logger.debug("OAuth endpoints discovered:", { + authorization: metadata.authorization_endpoint, + token: metadata.token_endpoint, + registration: metadata.registration_endpoint, + revocation: metadata.revocation_endpoint, + }); + + return metadata; + } + + private validateRequiredEndpoints(metadata: OAuthServerMetadata): void { + if ( + !metadata.authorization_endpoint || + !metadata.token_endpoint || + !metadata.issuer + ) { + throw new Error( + "OAuth server metadata missing required endpoints: " + + JSON.stringify(metadata), + ); + } + } + + private validateGrantTypes(metadata: OAuthServerMetadata): void { + if ( + !includesAllTypes(metadata.grant_types_supported, REQUIRED_GRANT_TYPES) + ) { + throw new Error( + `Server does not support required grant types: ${REQUIRED_GRANT_TYPES.join(", ")}. Supported: ${metadata.grant_types_supported?.join(", ") || "none"}`, + ); + } + } + + private validateResponseTypes(metadata: OAuthServerMetadata): void { + if (!includesAllTypes(metadata.response_types_supported, [RESPONSE_TYPE])) { + throw new Error( + `Server does not support required response type: ${RESPONSE_TYPE}. Supported: ${metadata.response_types_supported?.join(", ") || "none"}`, + ); + } + } + + private validateAuthMethods(metadata: OAuthServerMetadata): void { + if ( + !includesAllTypes(metadata.token_endpoint_auth_methods_supported, [ + OAUTH_METHOD, + ]) + ) { + throw new Error( + `Server does not support required auth method: ${OAUTH_METHOD}. Supported: ${metadata.token_endpoint_auth_methods_supported?.join(", ") || "none"}`, + ); + } + } + + private validatePKCEMethods(metadata: OAuthServerMetadata): void { + if ( + !includesAllTypes(metadata.code_challenge_methods_supported, [ + PKCE_CHALLENGE_METHOD, + ]) + ) { + throw new Error( + `Server does not support required PKCE method: ${PKCE_CHALLENGE_METHOD}. Supported: ${metadata.code_challenge_methods_supported?.join(", ") || "none"}`, + ); + } + } +} + +/** + * Check if an array includes all required types. + * If the array is undefined, returns true (server didn't specify, assume all allowed). + */ +function includesAllTypes( + arr: string[] | undefined, + requiredTypes: readonly string[], +): boolean { + if (arr === undefined) { + return true; + } + return requiredTypes.every((type) => arr.includes(type)); +} diff --git a/src/oauth/oauthHelper.ts b/src/oauth/oauthHelper.ts deleted file mode 100644 index efdc9b8b..00000000 --- a/src/oauth/oauthHelper.ts +++ /dev/null @@ -1,747 +0,0 @@ -import * as vscode from "vscode"; - -import { CoderApi } from "../api/coderApi"; -import { - type StoredOAuthTokens, - type SecretsManager, -} from "../core/secretsManager"; - -import { CALLBACK_PATH, generatePKCE, generateState } from "./utils"; - -import type { Logger } from "../logging/logger"; - -import type { - AuthorizationRequestParams, - ClientRegistrationRequest, - ClientRegistrationResponse, - OAuthServerMetadata, - RefreshTokenRequestParams, - TokenRequestParams, - TokenResponse, - TokenRevocationRequest, -} from "./types"; - -const AUTH_GRANT_TYPE = "authorization_code" as const; -const REFRESH_GRANT_TYPE = "refresh_token" as const; -const RESPONSE_TYPE = "code" as const; -const OAUTH_METHOD = "client_secret_post" as const; -const PKCE_CHALLENGE_METHOD = "S256" as const; -const CLIENT_NAME = "VS Code Coder Extension"; - -const REQUIRED_GRANT_TYPES = [AUTH_GRANT_TYPE, REFRESH_GRANT_TYPE] as const; - -// Token refresh timing constants (5 minutes before expiry) -const TOKEN_REFRESH_THRESHOLD_MS = 5 * 60 * 1000; - -/** - * Minimal scopes required by the VS Code extension: - * - workspace:read: List and read workspace details - * - workspace:update: Update workspace version - * - workspace:start: Start stopped workspaces - * - workspace:ssh: SSH configuration for remote connections - * - workspace:application_connect: Connect to workspace agents/apps - * - template:read: Read templates and versions - * - user:read_personal: Read authenticated user info - */ -const DEFAULT_OAUTH_SCOPES = [ - "workspace:read", - "workspace:update", - "workspace:start", - "workspace:ssh", - "workspace:application_connect", - "template:read", - "user:read_personal", -].join(" "); - -export class CoderOAuthHelper implements vscode.Disposable { - private readonly client: CoderApi; - - private clientRegistration: ClientRegistrationResponse | undefined; - private cachedMetadata: OAuthServerMetadata | undefined; - private pendingAuthResolve: - | ((value: { code: string; verifier: string }) => void) - | undefined; - private pendingAuthReject: ((reason: Error) => void) | undefined; - private expectedState: string | undefined; - private pendingVerifier: string | undefined; - private storedTokens: StoredOAuthTokens | undefined; - private refreshTimer: NodeJS.Timeout | undefined; - - private readonly extensionId: string; - - static async create( - baseUrl: string, - secretsManager: SecretsManager, - logger: Logger, - context: vscode.ExtensionContext, - ): Promise { - const helper = new CoderOAuthHelper( - baseUrl, - secretsManager, - logger, - context, - ); - await helper.loadClientRegistration(); - await helper.loadTokens(); - return helper; - } - private constructor( - baseUrl: string, - private readonly secretsManager: SecretsManager, - private readonly logger: Logger, - context: vscode.ExtensionContext, - ) { - this.client = CoderApi.create(baseUrl, undefined, logger); - this.extensionId = context.extension.id; - } - - private async getMetadata(): Promise { - if (this.cachedMetadata) { - return this.cachedMetadata; - } - - this.logger.info("Discovering OAuth endpoints..."); - - const response = await this.client - .getAxiosInstance() - .get("/.well-known/oauth-authorization-server"); - - const metadata = response.data; - - if ( - !metadata.authorization_endpoint || - !metadata.token_endpoint || - !metadata.issuer - ) { - throw new Error( - "OAuth server metadata missing required endpoints: " + - JSON.stringify(metadata), - ); - } - - if ( - !includesAllTypes(metadata.grant_types_supported, REQUIRED_GRANT_TYPES) - ) { - throw new Error( - `Server does not support required grant types: ${REQUIRED_GRANT_TYPES.join(", ")}. Supported: ${metadata.grant_types_supported?.join(", ") || "none"}`, - ); - } - - if (!includesAllTypes(metadata.response_types_supported, [RESPONSE_TYPE])) { - throw new Error( - `Server does not support required response type: ${RESPONSE_TYPE}. Supported: ${metadata.response_types_supported?.join(", ") || "none"}`, - ); - } - - if ( - !includesAllTypes(metadata.token_endpoint_auth_methods_supported, [ - OAUTH_METHOD, - ]) - ) { - throw new Error( - `Server does not support required auth method: ${OAUTH_METHOD}. Supported: ${metadata.token_endpoint_auth_methods_supported?.join(", ") || "none"}`, - ); - } - - if ( - !includesAllTypes(metadata.code_challenge_methods_supported, [ - PKCE_CHALLENGE_METHOD, - ]) - ) { - throw new Error( - `Server does not support required PKCE method: ${PKCE_CHALLENGE_METHOD}. Supported: ${metadata.code_challenge_methods_supported?.join(", ") || "none"}`, - ); - } - - this.cachedMetadata = metadata; - this.logger.debug("OAuth endpoints discovered:", { - authorization: metadata.authorization_endpoint, - token: metadata.token_endpoint, - registration: metadata.registration_endpoint, - revocation: metadata.revocation_endpoint, - }); - - return metadata; - } - - private getRedirectUri(): string { - return `${vscode.env.uriScheme}://${this.extensionId}${CALLBACK_PATH}`; - } - - private async loadClientRegistration(): Promise { - const registration = await this.secretsManager.getOAuthClientRegistration(); - if (registration) { - this.clientRegistration = registration; - this.logger.info("Loaded existing OAuth client:", registration.client_id); - } - } - - private async loadTokens(): Promise { - const tokens = await this.secretsManager.getOAuthTokens(); - if (tokens) { - if (!this.hasRequiredScopes(tokens.scope)) { - this.logger.warn( - "Stored token missing required scopes, clearing tokens", - { - stored_scope: tokens.scope, - required_scopes: DEFAULT_OAUTH_SCOPES, - }, - ); - await this.secretsManager.clearOAuthTokens(); - return; - } - - this.storedTokens = tokens; - this.logger.info("Loaded stored OAuth tokens", { - expires_at: new Date(tokens.expiry_timestamp).toISOString(), - scope: tokens.scope, - }); - this.client.setSessionToken(tokens.access_token); - - if (tokens.refresh_token) { - this.startRefreshTimer(); - } - } - } - - /** - * Check if granted scopes cover all required scopes. - * Supports wildcard scopes like "workspace:*" which grant all "workspace:" prefixed scopes. - */ - private hasRequiredScopes(grantedScope: string | undefined): boolean { - if (!grantedScope) { - return false; - } - - const grantedScopes = new Set(grantedScope.split(" ")); - const requiredScopes = DEFAULT_OAUTH_SCOPES.split(" "); - - for (const required of requiredScopes) { - // Check exact match - if (grantedScopes.has(required)) { - continue; - } - - // Check wildcard match (e.g., "workspace:*" grants "workspace:read") - const colonIndex = required.indexOf(":"); - if (colonIndex !== -1) { - const prefix = required.substring(0, colonIndex); - const wildcard = `${prefix}:*`; - if (grantedScopes.has(wildcard)) { - continue; - } - } - - return false; - } - - return true; - } - - private async saveClientRegistration( - registration: ClientRegistrationResponse, - ): Promise { - await this.secretsManager.setOAuthClientRegistration(registration); - this.clientRegistration = registration; - this.logger.info( - "Saved OAuth client registration:", - registration.client_id, - ); - } - - async clearClientRegistration(): Promise { - await this.secretsManager.setOAuthClientRegistration(undefined); - this.clientRegistration = undefined; - this.logger.info("Cleared OAuth client registration"); - } - - async registerClient(): Promise { - const redirectUri = this.getRedirectUri(); - - if (this.clientRegistration?.client_id) { - const clientId = this.clientRegistration.client_id; - if (this.clientRegistration.redirect_uris.includes(redirectUri)) { - this.logger.info("Using existing client registration:", clientId); - return clientId; - } - this.logger.info("Redirect URI changed, re-registering client"); - } - - const metadata = await this.getMetadata(); - - if (!metadata.registration_endpoint) { - throw new Error( - "Server does not support dynamic client registration (no registration_endpoint in metadata)", - ); - } - - // "web" type since VS Code Secrets API allows secure client_secret storage (confidential client). - const registrationRequest: ClientRegistrationRequest = { - redirect_uris: [redirectUri], - application_type: "web", - grant_types: [AUTH_GRANT_TYPE], - response_types: [RESPONSE_TYPE], - client_name: CLIENT_NAME, - token_endpoint_auth_method: OAUTH_METHOD, - }; - - const response = await this.client - .getAxiosInstance() - .post( - metadata.registration_endpoint, - registrationRequest, - ); - - await this.saveClientRegistration(response.data); - - return response.data.client_id; - } - - private buildAuthorizationUrl( - metadata: OAuthServerMetadata, - clientId: string, - state: string, - challenge: string, - scope: string, - ): string { - if (metadata.scopes_supported) { - const requestedScopes = scope.split(" "); - const unsupportedScopes = requestedScopes.filter( - (s) => !metadata.scopes_supported?.includes(s), - ); - if (unsupportedScopes.length > 0) { - this.logger.warn( - `Requested scopes not in server's supported scopes: ${unsupportedScopes.join(", ")}. Server may still accept them.`, - { supported_scopes: metadata.scopes_supported }, - ); - } - } - - const params: AuthorizationRequestParams = { - client_id: clientId, - response_type: RESPONSE_TYPE, - redirect_uri: this.getRedirectUri(), - scope, - state, - code_challenge: challenge, - code_challenge_method: PKCE_CHALLENGE_METHOD, - }; - - const url = `${metadata.authorization_endpoint}?${new URLSearchParams(params as unknown as Record).toString()}`; - - this.logger.debug("Building OAuth authorization URL:", { - client_id: clientId, - redirect_uri: this.getRedirectUri(), - scope, - }); - - return url; - } - - async startAuthorization( - url: string, - ): Promise<{ code: string; verifier: string }> { - this.client.setHost(url); - - const metadata = await this.getMetadata(); - const clientId = await this.registerClient(); - const state = generateState(); - const { verifier, challenge } = generatePKCE(); - - const authUrl = this.buildAuthorizationUrl( - metadata, - clientId, - state, - challenge, - DEFAULT_OAUTH_SCOPES, - ); - - return new Promise<{ code: string; verifier: string }>( - (resolve, reject) => { - const timeoutMins = 5; - const timeout = setTimeout( - () => { - this.clearPendingAuth(); - reject( - new Error(`OAuth flow timed out after ${timeoutMins} minutes`), - ); - }, - timeoutMins * 60 * 1000, - ); - - const clearPromise = () => { - clearTimeout(timeout); - this.clearPendingAuth(); - }; - - this.pendingAuthResolve = (result) => { - clearPromise(); - resolve(result); - }; - - this.pendingAuthReject = (error) => { - clearPromise(); - reject(error); - }; - - this.expectedState = state; - this.pendingVerifier = verifier; - - vscode.env.openExternal(vscode.Uri.parse(authUrl)).then( - () => {}, - (error) => { - if (error instanceof Error) { - this.pendingAuthReject?.(error); - } else { - this.pendingAuthReject?.(new Error("Failed to open browser")); - } - }, - ); - }, - ); - } - - private clearPendingAuth(): void { - this.pendingAuthResolve = undefined; - this.pendingAuthReject = undefined; - this.expectedState = undefined; - this.pendingVerifier = undefined; - } - - handleCallback( - code: string | null, - state: string | null, - error: string | null, - ): void { - if (!this.pendingAuthResolve || !this.pendingAuthReject) { - this.logger.warn("Received OAuth callback but no pending auth flow"); - return; - } - - if (error) { - this.pendingAuthReject(new Error(`OAuth error: ${error}`)); - return; - } - - if (!code) { - this.pendingAuthReject(new Error("No authorization code received")); - return; - } - - if (!state) { - this.pendingAuthReject(new Error("No state received")); - return; - } - - if (state !== this.expectedState) { - this.pendingAuthReject( - new Error("State mismatch - possible CSRF attack"), - ); - return; - } - - const verifier = this.pendingVerifier; - if (!verifier) { - this.pendingAuthReject(new Error("No PKCE verifier found")); - return; - } - - this.pendingAuthResolve({ code, verifier }); - } - - async exchangeToken(code: string, verifier: string): Promise { - const metadata = await this.getMetadata(); - - if (!this.clientRegistration) { - throw new Error("No client registration found"); - } - - this.logger.info("Exchanging authorization code for token"); - - const params: TokenRequestParams = { - grant_type: AUTH_GRANT_TYPE, - code, - redirect_uri: this.getRedirectUri(), - client_id: this.clientRegistration.client_id, - code_verifier: verifier, - }; - - if (this.clientRegistration.client_secret) { - params.client_secret = this.clientRegistration.client_secret; - } - - const tokenRequest = toUrlSearchParams(params); - - const response = await this.client - .getAxiosInstance() - .post(metadata.token_endpoint, tokenRequest, { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }); - - this.logger.info("Token exchange successful"); - - await this.saveTokens(response.data); - - return response.data; - } - - getClientId(): string | undefined { - return this.clientRegistration?.client_id; - } - - /** - * Refresh the access token using the stored refresh token. - */ - private async refreshToken(): Promise { - if (!this.storedTokens?.refresh_token) { - throw new Error("No refresh token available"); - } - - if (!this.clientRegistration) { - throw new Error("No client registration found"); - } - - const metadata = await this.getMetadata(); - - this.logger.debug("Refreshing access token"); - - const params: RefreshTokenRequestParams = { - grant_type: REFRESH_GRANT_TYPE, - refresh_token: this.storedTokens.refresh_token, - client_id: this.clientRegistration.client_id, - }; - - if (this.clientRegistration.client_secret) { - params.client_secret = this.clientRegistration.client_secret; - } - - const tokenRequest = toUrlSearchParams(params); - - const response = await this.client - .getAxiosInstance() - .post(metadata.token_endpoint, tokenRequest, { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }); - - this.logger.debug("Token refresh successful"); - - await this.saveTokens(response.data); - - return response.data; - } - - /** - * Save token response to secrets storage and restart the refresh timer. - */ - private async saveTokens(tokenResponse: TokenResponse): Promise { - const expiryTimestamp = tokenResponse.expires_in - ? Date.now() + tokenResponse.expires_in * 1000 - : Date.now() + 3600 * 1000; // Default to 1 hour if not specified - - const tokens: StoredOAuthTokens = { - ...tokenResponse, - expiry_timestamp: expiryTimestamp, - }; - - this.storedTokens = tokens; - await this.secretsManager.setOAuthTokens(tokens); - await this.secretsManager.setSessionToken(tokenResponse.access_token); - this.client.setSessionToken(tokens.access_token); - - this.logger.info("Tokens saved", { - expires_at: new Date(expiryTimestamp).toISOString(), - }); - - // Restart timer with new expiry (creates self-perpetuating refresh cycle) - this.startRefreshTimer(); - } - - /** - * Start the background token refresh timer. - * Sets a timeout to fire when the token is close to expiry. - */ - private startRefreshTimer(): void { - this.stopRefreshTimer(); - - if (!this.storedTokens?.refresh_token) { - this.logger.debug("No refresh token available, skipping timer setup"); - return; - } - - const now = Date.now(); - const timeUntilRefresh = - this.storedTokens.expiry_timestamp - TOKEN_REFRESH_THRESHOLD_MS - now; - - // If token is already expired or expires very soon, refresh immediately - if (timeUntilRefresh <= 0) { - this.logger.info("Token needs immediate refresh"); - this.refreshToken().catch((error) => { - this.logger.error("Immediate token refresh failed:", error); - }); - return; - } - - // Set timeout to fire exactly when token is 5 minutes from expiry - this.refreshTimer = setTimeout(() => { - this.logger.debug("Token refresh timer fired, refreshing token..."); - this.refreshToken().catch((error) => { - this.logger.error("Scheduled token refresh failed:", error); - }); - }, timeUntilRefresh); - - this.logger.debug("Token refresh timer scheduled", { - fires_at: new Date(now + timeUntilRefresh).toISOString(), - fires_in: timeUntilRefresh / 1000, - }); - } - - /** - * Stop the background token refresh timer. - */ - private stopRefreshTimer(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = undefined; - this.logger.debug("Background token refresh timer stopped"); - } - } - - /** - * Revoke a token using the OAuth server's revocation endpoint. - */ - private async revokeToken( - token: string, - tokenTypeHint?: "access_token" | "refresh_token", - ): Promise { - if (!this.clientRegistration) { - throw new Error("No client registration found"); - } - - const metadata = await this.getMetadata(); - - if (!metadata.revocation_endpoint) { - this.logger.warn( - "Server does not support token revocation (no revocation_endpoint)", - ); - return; - } - - this.logger.info("Revoking token", { tokenTypeHint }); - - const params: TokenRevocationRequest = { - token, - client_id: this.clientRegistration.client_id, - }; - - if (tokenTypeHint) { - params.token_type_hint = tokenTypeHint; - } - - if (this.clientRegistration.client_secret) { - params.client_secret = this.clientRegistration.client_secret; - } - - const revocationRequest = toUrlSearchParams(params); - - try { - await this.client - .getAxiosInstance() - .post(metadata.revocation_endpoint, revocationRequest, { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }); - - this.logger.info("Token revocation successful"); - } catch (error) { - this.logger.error("Token revocation failed:", error); - throw error; - } - } - - /** - * Logout by revoking tokens and clearing all OAuth data. - */ - async logout(): Promise { - this.stopRefreshTimer(); - - // Revoke refresh token (which also invalidates access token per RFC 7009) - if (this.storedTokens?.refresh_token) { - try { - await this.revokeToken( - this.storedTokens.refresh_token, - "refresh_token", - ); - } catch (error) { - this.logger.warn("Token revocation failed during logout:", error); - } - } - - await this.secretsManager.clearOAuthTokens(); - this.storedTokens = undefined; - await this.clearClientRegistration(); - - this.logger.info("OAuth logout complete"); - } - - /** - * Clears all in-memory state and rejects any pending operations. - */ - dispose(): void { - this.stopRefreshTimer(); - - if (this.pendingAuthReject) { - this.pendingAuthReject(new Error("OAuth helper disposed")); - } - this.clearPendingAuth(); - - this.storedTokens = undefined; - this.clientRegistration = undefined; - this.cachedMetadata = undefined; - - this.logger.debug("OAuth helper disposed, all state cleared"); - } -} - -function includesAllTypes( - arr: string[] | undefined, - requiredTypes: readonly string[], -): boolean { - if (arr === undefined) { - // Supported types are not sent by the server so just assume everything is allowed - return true; - } - - return requiredTypes.every((type) => arr.includes(type)); -} - -/** - * Converts an object with string properties to Record, - * filtering out undefined values for use with URLSearchParams. - */ -function toUrlSearchParams(obj: object): URLSearchParams { - const params = Object.fromEntries( - Object.entries(obj).filter( - ([, value]) => value !== undefined && typeof value === "string", - ), - ) as Record; - - return new URLSearchParams(params); -} - -/** - * Activates OAuth support for the Coder extension. - * Initializes and returns the OAuth helper. - */ -export async function activateCoderOAuth( - baseUrl: string, - secretsManager: SecretsManager, - logger: Logger, - context: vscode.ExtensionContext, -): Promise { - return CoderOAuthHelper.create(baseUrl, secretsManager, logger, context); -} diff --git a/src/oauth/sessionManager.ts b/src/oauth/sessionManager.ts new file mode 100644 index 00000000..f478184f --- /dev/null +++ b/src/oauth/sessionManager.ts @@ -0,0 +1,636 @@ +import axios, { type AxiosInstance } from "axios"; +import * as vscode from "vscode"; + +import { OAuthClientRegistry } from "./clientRegistry"; +import { OAuthMetadataClient } from "./metadataClient"; +import { OAuthTokenRefreshScheduler } from "./tokenRefreshScheduler"; +import { + CALLBACK_PATH, + generatePKCE, + generateState, + toUrlSearchParams, +} from "./utils"; + +import type { SecretsManager, StoredOAuthTokens } from "../core/secretsManager"; +import type { Logger } from "../logging/logger"; + +import type { + OAuthServerMetadata, + RefreshTokenRequestParams, + TokenRequestParams, + TokenResponse, + TokenRevocationRequest, +} from "./types"; + +const AUTH_GRANT_TYPE = "authorization_code" as const; +const REFRESH_GRANT_TYPE = "refresh_token" as const; +const RESPONSE_TYPE = "code" as const; +const PKCE_CHALLENGE_METHOD = "S256" as const; + +/** + * Minimal scopes required by the VS Code extension. + */ +const DEFAULT_OAUTH_SCOPES = [ + "workspace:read", + "workspace:update", + "workspace:start", + "workspace:ssh", + "workspace:application_connect", + "template:read", + "user:read_personal", +].join(" "); + +/** + * Manages OAuth session lifecycle for a Coder deployment. + * Coordinates authorization flow, token management, and automatic refresh. + */ +export class OAuthSessionManager implements vscode.Disposable { + private readonly extensionId: string; + private readonly refreshScheduler: OAuthTokenRefreshScheduler; + + private metadataClient: OAuthMetadataClient; + private clientRegistry: OAuthClientRegistry; + + private metadata: OAuthServerMetadata | undefined; + private storedTokens: StoredOAuthTokens | undefined; + + // Pending authorization flow state + private pendingAuthResolve: + | ((value: { code: string; verifier: string }) => void) + | undefined; + private pendingAuthReject: ((reason: Error) => void) | undefined; + private expectedState: string | undefined; + private pendingVerifier: string | undefined; + + /** + * Create and initialize a new OAuth session manager. + */ + static async create( + deploymentUrl: string, + secretsManager: SecretsManager, + logger: Logger, + context: vscode.ExtensionContext, + ): Promise { + const manager = new OAuthSessionManager( + deploymentUrl, + secretsManager, + logger, + context, + ); + await manager.initialize(); + return manager; + } + + private constructor( + private deploymentUrl: string, + private readonly secretsManager: SecretsManager, + private readonly logger: Logger, + context: vscode.ExtensionContext, + ) { + this.extensionId = context.extension.id; + + const axiosInstance = this.createAxiosInstance(); + + this.metadataClient = new OAuthMetadataClient(axiosInstance, logger); + this.clientRegistry = new OAuthClientRegistry( + axiosInstance, + secretsManager, + logger, + ); + this.refreshScheduler = new OAuthTokenRefreshScheduler(async () => { + await this.refreshToken(); + }, logger); + } + + /** + * Create axios instance for the current deployment URL. + */ + private createAxiosInstance(): AxiosInstance { + return axios.create({ + baseURL: this.deploymentUrl, + }); + } + + /** + * Initialize the session manager by loading persisted state. + */ + private async initialize(): Promise { + await this.clientRegistry.load(); + await this.loadTokens(); + } + + /** + * Load stored tokens and start refresh timer if applicable. + * Validates that tokens belong to the current deployment URL. + */ + private async loadTokens(): Promise { + const tokens = await this.secretsManager.getOAuthTokens(); + if (!tokens) { + return; + } + + // Validate URL match (only if we have a deploymentUrl set) + if ( + this.deploymentUrl && + tokens.deployment_url && + tokens.deployment_url !== this.deploymentUrl + ) { + this.logger.warn("Stored tokens for different deployment, clearing", { + stored: tokens.deployment_url, + current: this.deploymentUrl, + }); + await this.clearStaleData(); + return; + } + + if (!this.hasRequiredScopes(tokens.scope)) { + this.logger.warn( + "Stored token missing required scopes, clearing tokens", + { + stored_scope: tokens.scope, + required_scopes: DEFAULT_OAUTH_SCOPES, + }, + ); + await this.secretsManager.setOAuthTokens(undefined); + return; + } + + this.storedTokens = tokens; + this.logger.info("Loaded stored OAuth tokens", { + expires_at: new Date(tokens.expiry_timestamp).toISOString(), + scope: tokens.scope, + deployment: tokens.deployment_url, + }); + + if (tokens.refresh_token) { + this.refreshScheduler.schedule(tokens); + } + } + + /** + * Clear stale data when tokens don't match current deployment. + */ + private async clearStaleData(): Promise { + this.refreshScheduler.stop(); + await this.secretsManager.setOAuthTokens(undefined); + await this.clientRegistry.clear(); + } + + /** + * Clear all state when switching to a new deployment URL. + */ + private async clearForNewUrl(): Promise { + this.refreshScheduler.stop(); + this.metadata = undefined; + this.storedTokens = undefined; + await this.secretsManager.setOAuthTokens(undefined); + await this.clientRegistry.clear(); + } + + /** + * Check if granted scopes cover all required scopes. + * Supports wildcard scopes like "workspace:*". + */ + private hasRequiredScopes(grantedScope: string | undefined): boolean { + if (!grantedScope) { + return false; + } + + const grantedScopes = new Set(grantedScope.split(" ")); + const requiredScopes = DEFAULT_OAUTH_SCOPES.split(" "); + + for (const required of requiredScopes) { + if (grantedScopes.has(required)) { + continue; + } + + // Check wildcard match (e.g., "workspace:*" grants "workspace:read") + const colonIndex = required.indexOf(":"); + if (colonIndex !== -1) { + const prefix = required.substring(0, colonIndex); + const wildcard = `${prefix}:*`; + if (grantedScopes.has(wildcard)) { + continue; + } + } + + return false; + } + + return true; + } + + /** + * Get the redirect URI for OAuth callbacks. + */ + private getRedirectUri(): string { + return `${vscode.env.uriScheme}://${this.extensionId}${CALLBACK_PATH}`; + } + + /** + * Get OAuth server metadata, fetching if not already cached. + */ + private async getMetadata(): Promise { + this.metadata ??= await this.metadataClient.getMetadata(); + return this.metadata; + } + + /** + * Build authorization URL with all required OAuth 2.1 parameters. + */ + private buildAuthorizationUrl( + metadata: OAuthServerMetadata, + clientId: string, + state: string, + challenge: string, + ): string { + if (metadata.scopes_supported) { + const requestedScopes = DEFAULT_OAUTH_SCOPES.split(" "); + const unsupportedScopes = requestedScopes.filter( + (s) => !metadata.scopes_supported?.includes(s), + ); + if (unsupportedScopes.length > 0) { + this.logger.warn( + `Requested scopes not in server's supported scopes: ${unsupportedScopes.join(", ")}. Server may still accept them.`, + { supported_scopes: metadata.scopes_supported }, + ); + } + } + + const params = new URLSearchParams({ + client_id: clientId, + response_type: RESPONSE_TYPE, + redirect_uri: this.getRedirectUri(), + scope: DEFAULT_OAUTH_SCOPES, + state, + code_challenge: challenge, + code_challenge_method: PKCE_CHALLENGE_METHOD, + }); + + const url = `${metadata.authorization_endpoint}?${params.toString()}`; + + this.logger.debug("Built OAuth authorization URL:", { + client_id: clientId, + redirect_uri: this.getRedirectUri(), + scope: DEFAULT_OAUTH_SCOPES, + }); + + return url; + } + + /** + * Start OAuth authorization flow. + * Opens browser for user authentication and waits for callback. + * Returns authorization code and PKCE verifier on success. + * + * @param url Coder deployment URL to authenticate against + */ + async startAuthorization( + url: string, + ): Promise<{ code: string; verifier: string }> { + if (this.deploymentUrl !== url) { + this.logger.info("Deployment URL changed, clearing cached state", { + old: this.deploymentUrl, + new: url, + }); + await this.clearForNewUrl(); + this.deploymentUrl = url; + + // Recreate components with new axios instance for new URL + const axiosInstance = this.createAxiosInstance(); + this.metadataClient = new OAuthMetadataClient(axiosInstance, this.logger); + this.clientRegistry = new OAuthClientRegistry( + axiosInstance, + this.secretsManager, + this.logger, + ); + } + + // Clear cached metadata (may be stale) + this.metadata = undefined; + + const metadata = await this.getMetadata(); + const registration = await this.clientRegistry.register( + metadata, + this.getRedirectUri(), + ); + const state = generateState(); + const { verifier, challenge } = generatePKCE(); + + const authUrl = this.buildAuthorizationUrl( + metadata, + registration.client_id, + state, + challenge, + ); + + return new Promise<{ code: string; verifier: string }>( + (resolve, reject) => { + const timeoutMins = 5; + const timeout = setTimeout( + () => { + this.clearPendingAuth(); + reject( + new Error(`OAuth flow timed out after ${timeoutMins} minutes`), + ); + }, + timeoutMins * 60 * 1000, + ); + + const clearPromise = () => { + clearTimeout(timeout); + this.clearPendingAuth(); + }; + + this.pendingAuthResolve = (result) => { + clearPromise(); + resolve(result); + }; + + this.pendingAuthReject = (error) => { + clearPromise(); + reject(error); + }; + + this.expectedState = state; + this.pendingVerifier = verifier; + + vscode.env.openExternal(vscode.Uri.parse(authUrl)).then( + () => {}, + (error) => { + if (error instanceof Error) { + this.pendingAuthReject?.(error); + } else { + this.pendingAuthReject?.(new Error("Failed to open browser")); + } + }, + ); + }, + ); + } + + /** + * Clear pending authorization flow state. + */ + private clearPendingAuth(): void { + this.pendingAuthResolve = undefined; + this.pendingAuthReject = undefined; + this.expectedState = undefined; + this.pendingVerifier = undefined; + } + + /** + * Handle OAuth callback from browser redirect. + * Validates state and resolves pending authorization promise. + */ + handleCallback( + code: string | null, + state: string | null, + error: string | null, + ): void { + if (!this.pendingAuthResolve || !this.pendingAuthReject) { + this.logger.warn("Received OAuth callback but no pending auth flow"); + return; + } + + if (error) { + this.pendingAuthReject(new Error(`OAuth error: ${error}`)); + return; + } + + if (!code) { + this.pendingAuthReject(new Error("No authorization code received")); + return; + } + + if (!state) { + this.pendingAuthReject(new Error("No state received")); + return; + } + + if (state !== this.expectedState) { + this.pendingAuthReject( + new Error("State mismatch - possible CSRF attack"), + ); + return; + } + + const verifier = this.pendingVerifier; + if (!verifier) { + this.pendingAuthReject(new Error("No PKCE verifier found")); + return; + } + + this.pendingAuthResolve({ code, verifier }); + } + + /** + * Exchange authorization code for access token. + */ + async exchangeToken(code: string, verifier: string): Promise { + const metadata = await this.getMetadata(); + const registration = this.clientRegistry.get(); + + if (!registration) { + throw new Error("No client registration found"); + } + + this.logger.info("Exchanging authorization code for token"); + + const params: TokenRequestParams = { + grant_type: AUTH_GRANT_TYPE, + code, + redirect_uri: this.getRedirectUri(), + client_id: registration.client_id, + client_secret: registration.client_secret, + code_verifier: verifier, + }; + + const tokenRequest = toUrlSearchParams(params); + + const axiosInstance = this.createAxiosInstance(); + const response = await axiosInstance.post( + metadata.token_endpoint, + tokenRequest, + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + + this.logger.info("Token exchange successful"); + + await this.saveTokens(response.data); + + return response.data; + } + + /** + * Refresh the access token using the stored refresh token. + */ + private async refreshToken(): Promise { + if (!this.storedTokens?.refresh_token) { + throw new Error("No refresh token available"); + } + + const registration = this.clientRegistry.get(); + if (!registration) { + throw new Error("No client registration found"); + } + + const metadata = await this.getMetadata(); + + this.logger.debug("Refreshing access token"); + + const params: RefreshTokenRequestParams = { + grant_type: REFRESH_GRANT_TYPE, + refresh_token: this.storedTokens.refresh_token, + client_id: registration.client_id, + client_secret: registration.client_secret, + }; + + const tokenRequest = toUrlSearchParams(params); + + const axiosInstance = this.createAxiosInstance(); + const response = await axiosInstance.post( + metadata.token_endpoint, + tokenRequest, + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + + this.logger.debug("Token refresh successful"); + + await this.saveTokens(response.data); + + return response.data; + } + + /** + * Save token response to storage and schedule automatic refresh. + * Also triggers event via secretsManager to update global client. + */ + private async saveTokens(tokenResponse: TokenResponse): Promise { + const expiryTimestamp = tokenResponse.expires_in + ? Date.now() + tokenResponse.expires_in * 1000 + : Date.now() + 3600 * 1000; // TODO Default to 1 hour + + const tokens: StoredOAuthTokens = { + ...tokenResponse, + deployment_url: this.deploymentUrl, + expiry_timestamp: expiryTimestamp, + }; + + this.storedTokens = tokens; + await this.secretsManager.setOAuthTokens(tokens); + + // Trigger event to update global client (works for login & background refresh) + // TODO Add a setting to check if we have OAuth or token setup so we can start the background refresh + await this.secretsManager.setSessionToken(tokenResponse.access_token); + + this.logger.info("Tokens saved", { + expires_at: new Date(expiryTimestamp).toISOString(), + deployment: this.deploymentUrl, + }); + + // Schedule automatic refresh + this.refreshScheduler.schedule(tokens); + } + + /** + * Revoke a token using the OAuth server's revocation endpoint. + */ + private async revokeToken(token: string): Promise { + const registration = this.clientRegistry.get(); + if (!registration) { + throw new Error("No client registration found"); + } + + const metadata = await this.getMetadata(); + + if (!metadata.revocation_endpoint) { + this.logger.warn( + "Server does not support token revocation (no revocation_endpoint)", + ); + return; + } + + this.logger.info("Revoking refresh token"); + + const params: TokenRevocationRequest = { + token, + client_id: registration.client_id, + client_secret: registration.client_secret, + token_type_hint: "refresh_token", + }; + + const revocationRequest = toUrlSearchParams(params); + + try { + const axiosInstance = this.createAxiosInstance(); + await axiosInstance.post( + metadata.revocation_endpoint, + revocationRequest, + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + + this.logger.info("Token revocation successful"); + } catch (error) { + this.logger.error("Token revocation failed:", error); + throw error; + } + } + + /** + * Logout by revoking tokens and clearing all OAuth data. + */ + async logout(): Promise { + this.refreshScheduler.stop(); + + // Revoke refresh token (which also invalidates access token per RFC 7009) + if (this.storedTokens?.refresh_token) { + try { + await this.revokeToken(this.storedTokens.refresh_token); + } catch (error) { + this.logger.warn("Token revocation failed during logout:", error); + } + } + + await this.secretsManager.setOAuthTokens(undefined); + this.storedTokens = undefined; + await this.clientRegistry.clear(); + + this.logger.info("OAuth logout complete"); + } + + /** + * Get the client ID if registered. + */ + getClientId(): string | undefined { + return this.clientRegistry.get()?.client_id; + } + + /** + * Clears all in-memory state and rejects any pending operations. + */ + dispose(): void { + this.refreshScheduler.stop(); + + if (this.pendingAuthReject) { + this.pendingAuthReject(new Error("OAuth session manager disposed")); + } + this.clearPendingAuth(); + this.storedTokens = undefined; + this.metadata = undefined; + + this.logger.debug("OAuth session manager disposed"); + } +} diff --git a/src/oauth/tokenRefreshScheduler.ts b/src/oauth/tokenRefreshScheduler.ts new file mode 100644 index 00000000..3eeabb9e --- /dev/null +++ b/src/oauth/tokenRefreshScheduler.ts @@ -0,0 +1,65 @@ +import type { StoredOAuthTokens } from "../core/secretsManager"; +import type { Logger } from "../logging/logger"; + +// Token refresh timing constant +const TOKEN_REFRESH_THRESHOLD_MS = 20 * 60 * 1000; + +/** + * Manages automatic token refresh scheduling. + * Calculates optimal refresh timing and triggers refresh callbacks. + */ +export class OAuthTokenRefreshScheduler { + private refreshTimer: NodeJS.Timeout | undefined; + + constructor( + private readonly refreshCallback: () => Promise, + private readonly logger: Logger, + ) {} + + /** + * Schedule automatic token refresh based on token expiry. + */ + schedule(tokens: StoredOAuthTokens): void { + this.stop(); + + if (!tokens.refresh_token) { + this.logger.debug("No refresh token available, skipping timer setup"); + return; + } + + const now = Date.now(); + const timeUntilRefresh = + tokens.expiry_timestamp - TOKEN_REFRESH_THRESHOLD_MS - now; + + if (timeUntilRefresh <= 0) { + this.logger.info("Token needs immediate refresh"); + this.refreshCallback().catch((error) => { + this.logger.error("Immediate token refresh failed:", error); + }); + return; + } + + this.refreshTimer = setTimeout(() => { + this.logger.debug("Token refresh timer fired, refreshing token..."); + this.refreshCallback().catch((error) => { + this.logger.error("Scheduled token refresh failed:", error); + }); + }, timeUntilRefresh); + + this.logger.debug("Token refresh timer scheduled", { + fires_at: new Date(now + timeUntilRefresh).toISOString(), + fires_in_seconds: Math.round(timeUntilRefresh / 1000), + }); + } + + /** + * Stop the background token refresh timer. + */ + stop(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; + this.logger.debug("Token refresh timer stopped"); + } + } +} diff --git a/src/oauth/utils.ts b/src/oauth/utils.ts index 7d66a139..61beeb50 100644 --- a/src/oauth/utils.ts +++ b/src/oauth/utils.ts @@ -26,3 +26,17 @@ export function generatePKCE(): PKCEChallenge { export function generateState(): string { return randomBytes(16).toString("base64url"); } + +/** + * Converts an object with string properties to URLSearchParams, + * filtering out undefined values for use with OAuth requests. + */ +export function toUrlSearchParams(obj: object): URLSearchParams { + const params = Object.fromEntries( + Object.entries(obj).filter( + ([, value]) => value !== undefined && typeof value === "string", + ), + ) as Record; + + return new URLSearchParams(params); +} diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 97cb858e..b573f817 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -293,14 +293,14 @@ export class Remote { if (result.type === "login") { return this.setup(remoteAuthority, firstConnect); - } else if (!result.userChoice) { - // User declined to log in. - await this.closeRemote(); - return; - } else { + } else if (result.userChoice === "Log In") { // Log in then try again. await this.commands.login({ url: baseUrlRaw, label: parts.label }); return this.setup(remoteAuthority, firstConnect); + } else { + // User declined to log in. + await this.closeRemote(); + return; } }; From ee6f4a06928e4c495817a6dcb9638da374be2c4e Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 27 Oct 2025 12:37:25 +0300 Subject: [PATCH 09/11] Simplify the OAuth flow --- src/commands.ts | 55 +++---- src/extension.ts | 5 +- src/oauth/clientRegistry.ts | 111 -------------- src/oauth/sessionManager.ts | 287 +++++++++++++++++++++--------------- 4 files changed, 191 insertions(+), 267 deletions(-) delete mode 100644 src/oauth/clientRegistry.ts diff --git a/src/commands.ts b/src/commands.ts index a31a8b2f..90c47106 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -305,13 +305,14 @@ export class Commands { } if (choice === "oauth") { - return this.loginWithOAuth(url, client); + return this.loginWithOAuth(client); } else if (choice === "legacy") { - return this.loginWithToken(url, token, client); + const initialToken = + token || (await this.secretsManager.getSessionToken()); + return this.loginWithToken(client, initialToken); } - // User aborted. - return null; + return null; // User aborted. } private async checkOAuthSupport(client: CoderApi): Promise { @@ -350,10 +351,13 @@ export class Commands { } private async loginWithToken( - url: string, - token: string | undefined, client: CoderApi, + initialToken: string | undefined, ): Promise<{ user: User; token: string } | null> { + const url = client.getAxiosInstance().defaults.baseURL; + if (!url) { + throw new Error("No base URL set on REST client"); + } // This prompt is for convenience; do not error if they close it since // they may already have a token or already have the page opened. await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`)); @@ -366,7 +370,7 @@ export class Commands { title: "Coder API Key", password: true, placeHolder: "Paste your API key.", - value: token || (await this.secretsManager.getSessionToken()), + value: initialToken, ignoreFocusOut: true, validateInput: async (value) => { if (!value) { @@ -410,29 +414,17 @@ export class Commands { * Returns the access token and authenticated user, or null if failed/cancelled. */ private async loginWithOAuth( - url: string, client: CoderApi, ): Promise<{ user: User; token: string } | null> { try { this.logger.info("Starting OAuth authentication"); - // Start OAuth authorization flow - // TODO just pass the client here and do all the neccessary steps (If we are already logged in we'd have the right token and the OAuth client registration saved). - const { code, verifier } = - await this.oauthSessionManager.startAuthorization(url); - - // Exchange authorization code for tokens - const tokenResponse = await this.oauthSessionManager.exchangeToken( - code, - verifier, - ); + const tokenResponse = await this.oauthSessionManager.login(client); // Validate token by fetching user client.setSessionToken(tokenResponse.access_token); const user = await client.getAuthenticatedUser(); - this.logger.info("OAuth authentication successful"); - return { token: tokenResponse.access_token, user, @@ -481,9 +473,19 @@ export class Commands { throw new Error("You are not logged in"); } + await this.forceLogout(); + } + + public async forceLogout(): Promise { + if (!this.contextManager.get("coder.authenticated")) { + return; + } + this.logger.info("Logging out"); + // Check if using OAuth - const hasOAuthTokens = await this.secretsManager.getOAuthTokens(); - if (hasOAuthTokens) { + const isOAuthLoggedIn = + await this.oauthSessionManager.isLoggedInWithOAuth(); + if (isOAuthLoggedIn) { this.logger.info("Logging out via OAuth"); try { await this.oauthSessionManager.logout(); @@ -495,15 +497,6 @@ export class Commands { } } - // Continue with standard logout (clears sessionToken, contexts, etc) - await this.forceLogout(); - } - - public async forceLogout(): Promise { - if (!this.contextManager.get("coder.authenticated")) { - return; - } - this.logger.info("Logging out"); // Clear from the REST client. An empty url will indicate to other parts of // the code that we are logged out. this.restClient.setHost(""); diff --git a/src/extension.ts b/src/extension.ts index d4676abd..929d86df 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -134,19 +134,18 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Listen for session token changes and sync state across all components ctx.subscriptions.push( secretsManager.onDidChangeSessionToken(async (token) => { + client.setSessionToken(token ?? ""); if (!token) { output.debug("Session token cleared"); - client.setSessionToken(""); return; } output.debug("Session token changed, syncing state"); - client.setSessionToken(token); const url = mementoManager.getUrl(); if (url) { const cliManager = serviceContainer.getCliManager(); - // TODO label might not match? + // TODO label might not match the one in remote? await cliManager.configure(toSafeHost(url), url, token); output.debug("Updated CLI config with new token"); } diff --git a/src/oauth/clientRegistry.ts b/src/oauth/clientRegistry.ts deleted file mode 100644 index 91b4949f..00000000 --- a/src/oauth/clientRegistry.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { AxiosInstance } from "axios"; - -import type { SecretsManager } from "../core/secretsManager"; -import type { Logger } from "../logging/logger"; - -import type { - ClientRegistrationRequest, - ClientRegistrationResponse, - OAuthServerMetadata, -} from "./types"; - -const AUTH_GRANT_TYPE = "authorization_code" as const; -const RESPONSE_TYPE = "code" as const; -const OAUTH_METHOD = "client_secret_post" as const; -const CLIENT_NAME = "VS Code Coder Extension"; - -/** - * Manages OAuth client registration and persistence. - */ -export class OAuthClientRegistry { - private registration: ClientRegistrationResponse | undefined; - - constructor( - private readonly axiosInstance: AxiosInstance, - private readonly secretsManager: SecretsManager, - private readonly logger: Logger, - ) {} - - /** - * Load existing client registration from secure storage. - * Should be called during initialization. - */ - async load(): Promise { - const registration = await this.secretsManager.getOAuthClientRegistration(); - if (registration) { - this.registration = registration; - this.logger.info("Loaded existing OAuth client:", registration.client_id); - } - } - - /** - * Get the current client registration if one exists. - */ - get(): ClientRegistrationResponse | undefined { - return this.registration; - } - - /** - * Register a new OAuth client or return existing if still valid. - * Re-registers if redirect URI has changed. - */ - async register( - metadata: OAuthServerMetadata, - redirectUri: string, - ): Promise { - if (this.registration?.client_id) { - if (this.registration.redirect_uris.includes(redirectUri)) { - this.logger.info( - "Using existing client registration:", - this.registration.client_id, - ); - return this.registration; - } - this.logger.info("Redirect URI changed, re-registering client"); - } - - if (!metadata.registration_endpoint) { - throw new Error("Server does not support dynamic client registration"); - } - - // "web" type since VS Code Secrets API allows secure client_secret storage (confidential client) - const registrationRequest: ClientRegistrationRequest = { - redirect_uris: [redirectUri], - application_type: "web", - grant_types: [AUTH_GRANT_TYPE], - response_types: [RESPONSE_TYPE], - client_name: CLIENT_NAME, - token_endpoint_auth_method: OAUTH_METHOD, - }; - - const response = await this.axiosInstance.post( - metadata.registration_endpoint, - registrationRequest, - ); - - await this.save(response.data); - - return response.data; - } - - /** - * Save client registration to secure storage. - */ - private async save(registration: ClientRegistrationResponse): Promise { - await this.secretsManager.setOAuthClientRegistration(registration); - this.registration = registration; - this.logger.info( - "Saved OAuth client registration:", - registration.client_id, - ); - } - - /** - * Clear the current client registration from memory and storage. - */ - async clear(): Promise { - await this.secretsManager.setOAuthClientRegistration(undefined); - this.registration = undefined; - this.logger.info("Cleared OAuth client registration"); - } -} diff --git a/src/oauth/sessionManager.ts b/src/oauth/sessionManager.ts index f478184f..e41d5112 100644 --- a/src/oauth/sessionManager.ts +++ b/src/oauth/sessionManager.ts @@ -1,7 +1,8 @@ -import axios, { type AxiosInstance } from "axios"; +import { type AxiosInstance } from "axios"; import * as vscode from "vscode"; -import { OAuthClientRegistry } from "./clientRegistry"; +import { CoderApi } from "../api/coderApi"; + import { OAuthMetadataClient } from "./metadataClient"; import { OAuthTokenRefreshScheduler } from "./tokenRefreshScheduler"; import { @@ -15,6 +16,8 @@ import type { SecretsManager, StoredOAuthTokens } from "../core/secretsManager"; import type { Logger } from "../logging/logger"; import type { + ClientRegistrationRequest, + ClientRegistrationResponse, OAuthServerMetadata, RefreshTokenRequestParams, TokenRequestParams, @@ -48,10 +51,6 @@ export class OAuthSessionManager implements vscode.Disposable { private readonly extensionId: string; private readonly refreshScheduler: OAuthTokenRefreshScheduler; - private metadataClient: OAuthMetadataClient; - private clientRegistry: OAuthClientRegistry; - - private metadata: OAuthServerMetadata | undefined; private storedTokens: StoredOAuthTokens | undefined; // Pending authorization flow state @@ -77,7 +76,7 @@ export class OAuthSessionManager implements vscode.Disposable { logger, context, ); - await manager.initialize(); + await manager.loadTokens(); return manager; } @@ -88,37 +87,11 @@ export class OAuthSessionManager implements vscode.Disposable { context: vscode.ExtensionContext, ) { this.extensionId = context.extension.id; - - const axiosInstance = this.createAxiosInstance(); - - this.metadataClient = new OAuthMetadataClient(axiosInstance, logger); - this.clientRegistry = new OAuthClientRegistry( - axiosInstance, - secretsManager, - logger, - ); this.refreshScheduler = new OAuthTokenRefreshScheduler(async () => { await this.refreshToken(); }, logger); } - /** - * Create axios instance for the current deployment URL. - */ - private createAxiosInstance(): AxiosInstance { - return axios.create({ - baseURL: this.deploymentUrl, - }); - } - - /** - * Initialize the session manager by loading persisted state. - */ - private async initialize(): Promise { - await this.clientRegistry.load(); - await this.loadTokens(); - } - /** * Load stored tokens and start refresh timer if applicable. * Validates that tokens belong to the current deployment URL. @@ -129,19 +102,15 @@ export class OAuthSessionManager implements vscode.Disposable { return; } - // Validate URL match (only if we have a deploymentUrl set) - if ( - this.deploymentUrl && - tokens.deployment_url && - tokens.deployment_url !== this.deploymentUrl - ) { + if (this.deploymentUrl && tokens.deployment_url !== this.deploymentUrl) { this.logger.warn("Stored tokens for different deployment, clearing", { stored: tokens.deployment_url, current: this.deploymentUrl, }); - await this.clearStaleData(); + await this.clearTokenState(); return; } + this.deploymentUrl = tokens.deployment_url; if (!this.hasRequiredScopes(tokens.scope)) { this.logger.warn( @@ -156,11 +125,7 @@ export class OAuthSessionManager implements vscode.Disposable { } this.storedTokens = tokens; - this.logger.info("Loaded stored OAuth tokens", { - expires_at: new Date(tokens.expiry_timestamp).toISOString(), - scope: tokens.scope, - deployment: tokens.deployment_url, - }); + this.logger.info(`Loaded stored OAuth tokens for ${tokens.deployment_url}`); if (tokens.refresh_token) { this.refreshScheduler.schedule(tokens); @@ -170,21 +135,11 @@ export class OAuthSessionManager implements vscode.Disposable { /** * Clear stale data when tokens don't match current deployment. */ - private async clearStaleData(): Promise { + private async clearTokenState(): Promise { this.refreshScheduler.stop(); - await this.secretsManager.setOAuthTokens(undefined); - await this.clientRegistry.clear(); - } - - /** - * Clear all state when switching to a new deployment URL. - */ - private async clearForNewUrl(): Promise { - this.refreshScheduler.stop(); - this.metadata = undefined; this.storedTokens = undefined; await this.secretsManager.setOAuthTokens(undefined); - await this.clientRegistry.clear(); + await this.secretsManager.setOAuthClientRegistration(undefined); } /** @@ -193,7 +148,8 @@ export class OAuthSessionManager implements vscode.Disposable { */ private hasRequiredScopes(grantedScope: string | undefined): boolean { if (!grantedScope) { - return false; + // TODO server always returns empty scopes + return true; } const grantedScopes = new Set(grantedScope.split(" ")); @@ -228,11 +184,126 @@ export class OAuthSessionManager implements vscode.Disposable { } /** - * Get OAuth server metadata, fetching if not already cached. + * Prepare common OAuth operation setup: CoderApi, metadata, and registration. + * Used by refresh and revoke operations to reduce duplication. + */ + private async prepareOAuthOperation( + deploymentUrl: string, + token?: string, + ): Promise<{ + axiosInstance: AxiosInstance; + metadata: OAuthServerMetadata; + registration: ClientRegistrationResponse; + }> { + const client = CoderApi.create(deploymentUrl, token, this.logger); + const axiosInstance = client.getAxiosInstance(); + + const metadataClient = new OAuthMetadataClient(axiosInstance, this.logger); + const metadata = await metadataClient.getMetadata(); + + const registration = await this.secretsManager.getOAuthClientRegistration(); + if (!registration) { + throw new Error("No client registration found"); + } + + return { axiosInstance, metadata, registration }; + } + + /** + * Register OAuth client or return existing if still valid. + * Re-registers if redirect URI has changed. + */ + private async registerClient( + axiosInstance: AxiosInstance, + metadata: OAuthServerMetadata, + ): Promise { + const redirectUri = this.getRedirectUri(); + + const existing = await this.secretsManager.getOAuthClientRegistration(); + if (existing?.client_id) { + if (existing.redirect_uris.includes(redirectUri)) { + this.logger.info( + "Using existing client registration:", + existing.client_id, + ); + return existing; + } + this.logger.info("Redirect URI changed, re-registering client"); + } + + if (!metadata.registration_endpoint) { + throw new Error("Server does not support dynamic client registration"); + } + + const registrationRequest: ClientRegistrationRequest = { + redirect_uris: [redirectUri], + application_type: "web", + grant_types: ["authorization_code"], + response_types: ["code"], + client_name: "VS Code Coder Extension", + token_endpoint_auth_method: "client_secret_post", + }; + + const response = await axiosInstance.post( + metadata.registration_endpoint, + registrationRequest, + ); + + await this.secretsManager.setOAuthClientRegistration(response.data); + this.logger.info( + "Saved OAuth client registration:", + response.data.client_id, + ); + + return response.data; + } + + /** + * Simplified OAuth login flow that handles the entire process. + * Fetches metadata, registers client, starts authorization, and exchanges tokens. + * + * @param client CoderApi instance for the deployment to authenticate against + * @returns TokenResponse containing access token and optional refresh token */ - private async getMetadata(): Promise { - this.metadata ??= await this.metadataClient.getMetadata(); - return this.metadata; + async login(client: CoderApi): Promise { + const baseUrl = client.getAxiosInstance().defaults.baseURL; + if (!baseUrl) { + throw new Error("CoderApi instance has no base URL set"); + } + if (this.deploymentUrl !== baseUrl) { + this.logger.info("Deployment URL changed, clearing cached state", { + old: this.deploymentUrl, + new: baseUrl, + }); + await this.clearTokenState(); + this.deploymentUrl = baseUrl; + } + + this.logger.info("Starting OAuth login flow"); + + const axiosInstance = client.getAxiosInstance(); + const metadataClient = new OAuthMetadataClient(axiosInstance, this.logger); + const metadata = await metadataClient.getMetadata(); + + // Only register the client on login + const registration = await this.registerClient(axiosInstance, metadata); + + const { code, verifier } = await this.startAuthorization( + metadata, + registration, + ); + + const tokenResponse = await this.exchangeToken( + code, + verifier, + axiosInstance, + metadata, + registration, + ); + + this.logger.info("OAuth login flow completed successfully"); + + return tokenResponse; } /** @@ -282,38 +353,11 @@ export class OAuthSessionManager implements vscode.Disposable { * Start OAuth authorization flow. * Opens browser for user authentication and waits for callback. * Returns authorization code and PKCE verifier on success. - * - * @param url Coder deployment URL to authenticate against */ - async startAuthorization( - url: string, + private async startAuthorization( + metadata: OAuthServerMetadata, + registration: ClientRegistrationResponse, ): Promise<{ code: string; verifier: string }> { - if (this.deploymentUrl !== url) { - this.logger.info("Deployment URL changed, clearing cached state", { - old: this.deploymentUrl, - new: url, - }); - await this.clearForNewUrl(); - this.deploymentUrl = url; - - // Recreate components with new axios instance for new URL - const axiosInstance = this.createAxiosInstance(); - this.metadataClient = new OAuthMetadataClient(axiosInstance, this.logger); - this.clientRegistry = new OAuthClientRegistry( - axiosInstance, - this.secretsManager, - this.logger, - ); - } - - // Clear cached metadata (may be stale) - this.metadata = undefined; - - const metadata = await this.getMetadata(); - const registration = await this.clientRegistry.register( - metadata, - this.getRedirectUri(), - ); const state = generateState(); const { verifier, challenge } = generatePKCE(); @@ -382,6 +426,8 @@ export class OAuthSessionManager implements vscode.Disposable { /** * Handle OAuth callback from browser redirect. * Validates state and resolves pending authorization promise. + * + * // TODO this has to work across windows! */ handleCallback( code: string | null, @@ -427,14 +473,13 @@ export class OAuthSessionManager implements vscode.Disposable { /** * Exchange authorization code for access token. */ - async exchangeToken(code: string, verifier: string): Promise { - const metadata = await this.getMetadata(); - const registration = this.clientRegistry.get(); - - if (!registration) { - throw new Error("No client registration found"); - } - + private async exchangeToken( + code: string, + verifier: string, + axiosInstance: AxiosInstance, + metadata: OAuthServerMetadata, + registration: ClientRegistrationResponse, + ): Promise { this.logger.info("Exchanging authorization code for token"); const params: TokenRequestParams = { @@ -448,7 +493,6 @@ export class OAuthSessionManager implements vscode.Disposable { const tokenRequest = toUrlSearchParams(params); - const axiosInstance = this.createAxiosInstance(); const response = await axiosInstance.post( metadata.token_endpoint, tokenRequest, @@ -474,12 +518,11 @@ export class OAuthSessionManager implements vscode.Disposable { throw new Error("No refresh token available"); } - const registration = this.clientRegistry.get(); - if (!registration) { - throw new Error("No client registration found"); - } - - const metadata = await this.getMetadata(); + const { axiosInstance, metadata, registration } = + await this.prepareOAuthOperation( + this.deploymentUrl, + this.storedTokens.access_token, + ); this.logger.debug("Refreshing access token"); @@ -492,7 +535,6 @@ export class OAuthSessionManager implements vscode.Disposable { const tokenRequest = toUrlSearchParams(params); - const axiosInstance = this.createAxiosInstance(); const response = await axiosInstance.post( metadata.token_endpoint, tokenRequest, @@ -545,12 +587,11 @@ export class OAuthSessionManager implements vscode.Disposable { * Revoke a token using the OAuth server's revocation endpoint. */ private async revokeToken(token: string): Promise { - const registration = this.clientRegistry.get(); - if (!registration) { - throw new Error("No client registration found"); - } - - const metadata = await this.getMetadata(); + const { axiosInstance, metadata, registration } = + await this.prepareOAuthOperation( + this.deploymentUrl, + this.storedTokens?.access_token, + ); if (!metadata.revocation_endpoint) { this.logger.warn( @@ -571,7 +612,6 @@ export class OAuthSessionManager implements vscode.Disposable { const revocationRequest = toUrlSearchParams(params); try { - const axiosInstance = this.createAxiosInstance(); await axiosInstance.post( metadata.revocation_endpoint, revocationRequest, @@ -604,18 +644,22 @@ export class OAuthSessionManager implements vscode.Disposable { } } - await this.secretsManager.setOAuthTokens(undefined); - this.storedTokens = undefined; - await this.clientRegistry.clear(); + await this.clearTokenState(); this.logger.info("OAuth logout complete"); } /** - * Get the client ID if registered. + * Check if currently logged in with OAuth. + * Returns true only if valid OAuth tokens exist for the current deployment. */ - getClientId(): string | undefined { - return this.clientRegistry.get()?.client_id; + async isLoggedInWithOAuth(): Promise { + const tokens = await this.secretsManager.getOAuthTokens(); + if (!tokens) { + return false; + } + + return this.deploymentUrl === tokens.deployment_url; } /** @@ -629,7 +673,6 @@ export class OAuthSessionManager implements vscode.Disposable { } this.clearPendingAuth(); this.storedTokens = undefined; - this.metadata = undefined; this.logger.debug("OAuth session manager disposed"); } From b93e027fcffbadd4fe1d1c61f875e0230b205e87 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 28 Oct 2025 18:41:17 +0300 Subject: [PATCH 10/11] Add error handling --- src/api/coderApi.ts | 93 ++++++++++++- src/commands.ts | 31 +---- src/extension.ts | 21 +-- src/oauth/errors.ts | 173 +++++++++++++++++++++++ src/oauth/metadataClient.ts | 2 +- src/oauth/sessionManager.ts | 211 ++++++++++++++++++----------- src/oauth/tokenRefreshScheduler.ts | 65 --------- 7 files changed, 410 insertions(+), 186 deletions(-) create mode 100644 src/oauth/errors.ts delete mode 100644 src/oauth/tokenRefreshScheduler.ts diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index da624bad..04789395 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -3,6 +3,7 @@ import { type AxiosInstance, type AxiosHeaders, type AxiosResponseTransformer, + isAxiosError, } from "axios"; import { Api } from "coder/site/src/api/api"; import { @@ -30,6 +31,12 @@ import { HttpClientLogLevel, } from "../logging/types"; import { sizeOf } from "../logging/utils"; +import { + parseOAuthError, + requiresReAuthentication, + isNetworkError, +} from "../oauth/errors"; +import { type OAuthSessionManager } from "../oauth/sessionManager"; import { type UnidirectionalStream } from "../websocket/eventStreamConnection"; import { OneWayWebSocket, @@ -58,6 +65,7 @@ export class CoderApi extends Api { baseUrl: string, token: string | undefined, output: Logger, + oauthSessionManager?: OAuthSessionManager, ): CoderApi { const client = new CoderApi(output); client.setHost(baseUrl); @@ -65,7 +73,7 @@ export class CoderApi extends Api { client.setSessionToken(token); } - setupInterceptors(client, baseUrl, output); + setupInterceptors(client, baseUrl, output, oauthSessionManager); return client; } @@ -302,6 +310,7 @@ function setupInterceptors( client: CoderApi, baseUrl: string, output: Logger, + oauthSessionManager?: OAuthSessionManager, ): void { addLoggingInterceptors(client.getAxiosInstance(), output); @@ -334,6 +343,11 @@ function setupInterceptors( throw await CertificateError.maybeWrap(err, baseUrl, output); }, ); + + // OAuth token refresh interceptors + if (oauthSessionManager) { + addOAuthInterceptors(client, output, oauthSessionManager); + } } function addLoggingInterceptors(client: AxiosInstance, logger: Logger) { @@ -363,7 +377,7 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) { }, (error: unknown) => { logError(logger, error, getLogLevel()); - return Promise.reject(error); + throw error; }, ); @@ -374,7 +388,80 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) { }, (error: unknown) => { logError(logger, error, getLogLevel()); - return Promise.reject(error); + throw error; + }, + ); +} + +/** + * Add OAuth token refresh interceptors. + * Success interceptor: proactively refreshes token when approaching expiry. + * Error interceptor: reactively refreshes token on 401/403 responses. + */ +function addOAuthInterceptors( + client: CoderApi, + logger: Logger, + oauthSessionManager: OAuthSessionManager, +) { + client.getAxiosInstance().interceptors.response.use( + // Success response interceptor: proactive token refresh + (response) => { + if (oauthSessionManager.shouldRefreshToken()) { + logger.debug( + "Token approaching expiry, triggering proactive refresh in background", + ); + + // Fire-and-forget: don't await, don't block response + oauthSessionManager.refreshToken().catch((error) => { + logger.warn("Background token refresh failed:", error); + }); + } + + return response; + }, + // Error response interceptor: reactive token refresh on 401/403 + async (error: unknown) => { + if (!isAxiosError(error)) { + throw error; + } + + const status = error.response?.status; + if (status !== 401 && status !== 403) { + throw error; + } + + if (!oauthSessionManager.isLoggedInWithOAuth()) { + throw error; + } + + logger.info(`Received ${status} response, attempting token refresh`); + + try { + const newTokens = await oauthSessionManager.refreshToken(); + client.setSessionToken(newTokens.access_token); + + logger.info("Token refresh successful, updated session token"); + } catch (refreshError) { + logger.error("Token refresh failed:", refreshError); + + const oauthError = parseOAuthError(refreshError); + if (oauthError && requiresReAuthentication(oauthError)) { + logger.error( + `OAuth error requires re-authentication: ${oauthError.errorCode}`, + ); + + oauthSessionManager + .showReAuthenticationModal(oauthError) + .catch((err) => { + logger.error("Failed to show re-auth modal:", err); + }); + } else if (isNetworkError(refreshError)) { + logger.warn( + "Token refresh failed due to network error, will retry later", + ); + } + } + throw error; }, ); } diff --git a/src/commands.ts b/src/commands.ts index 90c47106..676d539c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -482,20 +482,10 @@ export class Commands { } this.logger.info("Logging out"); - // Check if using OAuth - const isOAuthLoggedIn = - await this.oauthSessionManager.isLoggedInWithOAuth(); - if (isOAuthLoggedIn) { - this.logger.info("Logging out via OAuth"); - try { - await this.oauthSessionManager.logout(); - } catch (error) { - this.logger.warn( - "OAuth logout failed, continuing with cleanup:", - error, - ); - } - } + // Fire and forget + this.oauthSessionManager.logout().catch((error) => { + this.logger.warn("OAuth logout failed, continuing with cleanup:", error); + }); // Clear from the REST client. An empty url will indicate to other parts of // the code that we are logged out. @@ -667,19 +657,6 @@ export class Commands { }, ); } - // Check if app has a URL to open - if (app.url) { - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Opening ${app.name || "application"} in browser...`, - cancellable: false, - }, - async () => { - await vscode.env.openExternal(vscode.Uri.parse(app.url!)); - }, - ); - } // If no URL or command, show information about the app status vscode.window.showInformationMessage(`${app.name}`, { diff --git a/src/extension.ts b/src/extension.ts index 929d86df..79fce060 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -70,14 +70,24 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Try to clear this flag ASAP const isFirstConnect = await mementoManager.getAndClearFirstConnect(); + const url = mementoManager.getUrl(); + + // Create OAuth session manager before the main client + const oauthSessionManager = await OAuthSessionManager.create( + url || "", + serviceContainer, + ctx, + ); + ctx.subscriptions.push(oauthSessionManager); + // This client tracks the current login and will be used through the life of // the plugin to poll workspaces for the current login, as well as being used // in commands that operate on the current login. - const url = mementoManager.getUrl(); const client = CoderApi.create( url || "", await secretsManager.getSessionToken(), output, + oauthSessionManager, ); const myWorkspacesProvider = new WorkspaceProvider( @@ -123,14 +133,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.subscriptions, ); - const oauthSessionManager = await OAuthSessionManager.create( - url || "", - secretsManager, - output, - ctx, - ); - ctx.subscriptions.push(oauthSessionManager); - // Listen for session token changes and sync state across all components ctx.subscriptions.push( secretsManager.onDidChangeSessionToken(async (token) => { @@ -409,6 +411,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { isFirstConnect, ); if (details) { + // TODO if the URL is different then we need to update the OAuth session!!! (Centralize this logic) ctx.subscriptions.push(details); // Authenticate the plugin client which is used in the sidebar to display // workspaces belonging to this deployment. diff --git a/src/oauth/errors.ts b/src/oauth/errors.ts new file mode 100644 index 00000000..67c7cd47 --- /dev/null +++ b/src/oauth/errors.ts @@ -0,0 +1,173 @@ +import { isAxiosError } from "axios"; + +import type { OAuthErrorResponse } from "./types"; + +/** + * Base class for OAuth errors + */ +export class OAuthError extends Error { + constructor( + message: string, + public readonly errorCode: string, + public readonly description?: string, + public readonly errorUri?: string, + ) { + super(message); + this.name = "OAuthError"; + } +} + +/** + * Refresh token is invalid, expired, or revoked. Requires re-authentication. + */ +export class InvalidGrantError extends OAuthError { + constructor(description?: string, errorUri?: string) { + super( + "OAuth refresh token is invalid, expired, or revoked", + "invalid_grant", + description, + errorUri, + ); + this.name = "InvalidGrantError"; + } +} + +/** + * Client credentials are invalid. Requires re-registration. + */ +export class InvalidClientError extends OAuthError { + constructor(description?: string, errorUri?: string) { + super( + "OAuth client credentials are invalid", + "invalid_client", + description, + errorUri, + ); + this.name = "InvalidClientError"; + } +} + +/** + * Invalid request error - malformed OAuth request + */ +export class InvalidRequestError extends OAuthError { + constructor(description?: string, errorUri?: string) { + super( + "OAuth request is malformed or invalid", + "invalid_request", + description, + errorUri, + ); + this.name = "InvalidRequestError"; + } +} + +/** + * Client is not authorized for this grant type. + */ +export class UnauthorizedClientError extends OAuthError { + constructor(description?: string, errorUri?: string) { + super( + "OAuth client is not authorized for this grant type", + "unauthorized_client", + description, + errorUri, + ); + this.name = "UnauthorizedClientError"; + } +} + +/** + * Unsupported grant type error. + */ +export class UnsupportedGrantTypeError extends OAuthError { + constructor(description?: string, errorUri?: string) { + super( + "OAuth grant type is not supported", + "unsupported_grant_type", + description, + errorUri, + ); + this.name = "UnsupportedGrantTypeError"; + } +} + +/** + * Invalid scope error. + */ +export class InvalidScopeError extends OAuthError { + constructor(description?: string, errorUri?: string) { + super( + "OAuth scope is invalid, unknown, malformed, or exceeds the scope granted by the resource owner", + "invalid_scope", + description, + errorUri, + ); + this.name = "InvalidScopeError"; + } +} + +/** + * Parses an axios error to extract OAuth error information + * Returns an OAuthError instance if the error is OAuth-related, otherwise returns null + */ +export function parseOAuthError(error: unknown): OAuthError | null { + if (!isAxiosError(error)) { + return null; + } + + const data = error.response?.data; + + if (!isOAuthErrorResponse(data)) { + return null; + } + + const { error: errorCode, error_description, error_uri } = data; + + switch (errorCode) { + case "invalid_grant": + return new InvalidGrantError(error_description, error_uri); + case "invalid_client": + return new InvalidClientError(error_description, error_uri); + case "invalid_request": + return new InvalidRequestError(error_description, error_uri); + case "unauthorized_client": + return new UnauthorizedClientError(error_description, error_uri); + case "unsupported_grant_type": + return new UnsupportedGrantTypeError(error_description, error_uri); + case "invalid_scope": + return new InvalidScopeError(error_description, error_uri); + default: + return new OAuthError( + `OAuth error: ${errorCode}`, + errorCode, + error_description, + error_uri, + ); + } +} + +function isOAuthErrorResponse(data: unknown): data is OAuthErrorResponse { + return ( + data !== null && + typeof data === "object" && + "error" in data && + typeof data.error === "string" + ); +} + +/** + * Checks if an error requires re-authentication + */ +export function requiresReAuthentication(error: OAuthError): boolean { + return ( + error instanceof InvalidGrantError || error instanceof InvalidClientError + ); +} + +/** + * Checks if an error is a network/connectivity error + */ +export function isNetworkError(error: unknown): boolean { + return isAxiosError(error) && !error.response && Boolean(error.request); +} diff --git a/src/oauth/metadataClient.ts b/src/oauth/metadataClient.ts index 7f3227dc..98568525 100644 --- a/src/oauth/metadataClient.ts +++ b/src/oauth/metadataClient.ts @@ -42,7 +42,7 @@ export class OAuthMetadataClient { * Throws detailed errors if server doesn't meet OAuth 2.1 requirements. */ async getMetadata(): Promise { - this.logger.info("Discovering OAuth endpoints..."); + this.logger.debug("Discovering OAuth endpoints..."); const response = await this.axiosInstance.get( OAUTH_DISCOVERY_ENDPOINT, diff --git a/src/oauth/sessionManager.ts b/src/oauth/sessionManager.ts index e41d5112..77fcec63 100644 --- a/src/oauth/sessionManager.ts +++ b/src/oauth/sessionManager.ts @@ -1,10 +1,11 @@ import { type AxiosInstance } from "axios"; import * as vscode from "vscode"; +import { type ServiceContainer } from "src/core/container"; + import { CoderApi } from "../api/coderApi"; import { OAuthMetadataClient } from "./metadataClient"; -import { OAuthTokenRefreshScheduler } from "./tokenRefreshScheduler"; import { CALLBACK_PATH, generatePKCE, @@ -15,6 +16,7 @@ import { import type { SecretsManager, StoredOAuthTokens } from "../core/secretsManager"; import type { Logger } from "../logging/logger"; +import type { OAuthError } from "./errors"; import type { ClientRegistrationRequest, ClientRegistrationResponse, @@ -30,6 +32,16 @@ const REFRESH_GRANT_TYPE = "refresh_token" as const; const RESPONSE_TYPE = "code" as const; const PKCE_CHALLENGE_METHOD = "S256" as const; +/** + * Token refresh threshold: refresh when token expires in less than this time + */ +const TOKEN_REFRESH_THRESHOLD_MS = 10 * 60 * 1000; + +/** + * Minimum time between refresh attempts to prevent thrashing + */ +const REFRESH_THROTTLE_MS = 30 * 1000; + /** * Minimal scopes required by the VS Code extension. */ @@ -48,10 +60,9 @@ const DEFAULT_OAUTH_SCOPES = [ * Coordinates authorization flow, token management, and automatic refresh. */ export class OAuthSessionManager implements vscode.Disposable { - private readonly extensionId: string; - private readonly refreshScheduler: OAuthTokenRefreshScheduler; - private storedTokens: StoredOAuthTokens | undefined; + private refreshInProgress = false; + private lastRefreshAttempt = 0; // Pending authorization flow state private pendingAuthResolve: @@ -66,15 +77,15 @@ export class OAuthSessionManager implements vscode.Disposable { */ static async create( deploymentUrl: string, - secretsManager: SecretsManager, - logger: Logger, + container: ServiceContainer, context: vscode.ExtensionContext, ): Promise { const manager = new OAuthSessionManager( deploymentUrl, - secretsManager, - logger, - context, + container.getSecretsManager(), + container.getLogger(), + container.getVsCodeProposed(), + context.extension.id, ); await manager.loadTokens(); return manager; @@ -84,16 +95,12 @@ export class OAuthSessionManager implements vscode.Disposable { private deploymentUrl: string, private readonly secretsManager: SecretsManager, private readonly logger: Logger, - context: vscode.ExtensionContext, - ) { - this.extensionId = context.extension.id; - this.refreshScheduler = new OAuthTokenRefreshScheduler(async () => { - await this.refreshToken(); - }, logger); - } + private readonly vscodeProposed: typeof vscode, + private readonly extensionId: string, + ) {} /** - * Load stored tokens and start refresh timer if applicable. + * Load stored tokens from storage. * Validates that tokens belong to the current deployment URL. */ private async loadTokens(): Promise { @@ -126,18 +133,15 @@ export class OAuthSessionManager implements vscode.Disposable { this.storedTokens = tokens; this.logger.info(`Loaded stored OAuth tokens for ${tokens.deployment_url}`); - - if (tokens.refresh_token) { - this.refreshScheduler.schedule(tokens); - } } /** * Clear stale data when tokens don't match current deployment. */ private async clearTokenState(): Promise { - this.refreshScheduler.stop(); this.storedTokens = undefined; + this.refreshInProgress = false; + this.lastRefreshAttempt = 0; await this.secretsManager.setOAuthTokens(undefined); await this.secretsManager.setOAuthClientRegistration(undefined); } @@ -270,7 +274,7 @@ export class OAuthSessionManager implements vscode.Disposable { if (!baseUrl) { throw new Error("CoderApi instance has no base URL set"); } - if (this.deploymentUrl !== baseUrl) { + if (this.deploymentUrl && this.deploymentUrl !== baseUrl) { this.logger.info("Deployment URL changed, clearing cached state", { old: this.deploymentUrl, new: baseUrl, @@ -279,8 +283,6 @@ export class OAuthSessionManager implements vscode.Disposable { this.deploymentUrl = baseUrl; } - this.logger.info("Starting OAuth login flow"); - const axiosInstance = client.getAxiosInstance(); const metadataClient = new OAuthMetadataClient(axiosInstance, this.logger); const metadata = await metadataClient.getMetadata(); @@ -512,48 +514,60 @@ export class OAuthSessionManager implements vscode.Disposable { /** * Refresh the access token using the stored refresh token. + * Uses a mutex to prevent concurrent refresh attempts. */ - private async refreshToken(): Promise { + async refreshToken(): Promise { + if (this.refreshInProgress) { + throw new Error("Token refresh already in progress"); + } + if (!this.storedTokens?.refresh_token) { throw new Error("No refresh token available"); } - const { axiosInstance, metadata, registration } = - await this.prepareOAuthOperation( - this.deploymentUrl, - this.storedTokens.access_token, - ); + this.refreshInProgress = true; + this.lastRefreshAttempt = Date.now(); - this.logger.debug("Refreshing access token"); + try { + const { axiosInstance, metadata, registration } = + await this.prepareOAuthOperation( + this.deploymentUrl, + this.storedTokens.access_token, + ); - const params: RefreshTokenRequestParams = { - grant_type: REFRESH_GRANT_TYPE, - refresh_token: this.storedTokens.refresh_token, - client_id: registration.client_id, - client_secret: registration.client_secret, - }; + this.logger.debug("Refreshing access token"); - const tokenRequest = toUrlSearchParams(params); + const params: RefreshTokenRequestParams = { + grant_type: REFRESH_GRANT_TYPE, + refresh_token: this.storedTokens.refresh_token, + client_id: registration.client_id, + client_secret: registration.client_secret, + }; - const response = await axiosInstance.post( - metadata.token_endpoint, - tokenRequest, - { - headers: { - "Content-Type": "application/x-www-form-urlencoded", + const tokenRequest = toUrlSearchParams(params); + + const response = await axiosInstance.post( + metadata.token_endpoint, + tokenRequest, + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, }, - }, - ); + ); - this.logger.debug("Token refresh successful"); + this.logger.debug("Token refresh successful"); - await this.saveTokens(response.data); + await this.saveTokens(response.data); - return response.data; + return response.data; + } finally { + this.refreshInProgress = false; + } } /** - * Save token response to storage and schedule automatic refresh. + * Save token response to storage. * Also triggers event via secretsManager to update global client. */ private async saveTokens(tokenResponse: TokenResponse): Promise { @@ -571,34 +585,54 @@ export class OAuthSessionManager implements vscode.Disposable { await this.secretsManager.setOAuthTokens(tokens); // Trigger event to update global client (works for login & background refresh) - // TODO Add a setting to check if we have OAuth or token setup so we can start the background refresh await this.secretsManager.setSessionToken(tokenResponse.access_token); this.logger.info("Tokens saved", { expires_at: new Date(expiryTimestamp).toISOString(), deployment: this.deploymentUrl, }); + } + + /** + * Check if token should be refreshed. + * Returns true if: + * 1. Token expires in less than TOKEN_REFRESH_THRESHOLD_MS + * 2. Last refresh attempt was more than REFRESH_THROTTLE_MS ago + * 3. No refresh is currently in progress + */ + shouldRefreshToken(): boolean { + if ( + !this.isLoggedInWithOAuth() || + !this.storedTokens?.refresh_token || + this.refreshInProgress + ) { + return false; + } - // Schedule automatic refresh - this.refreshScheduler.schedule(tokens); + const now = Date.now(); + if (now - this.lastRefreshAttempt < REFRESH_THROTTLE_MS) { + return false; + } + + const timeUntilExpiry = this.storedTokens.expiry_timestamp - now; + return timeUntilExpiry < TOKEN_REFRESH_THRESHOLD_MS; } /** * Revoke a token using the OAuth server's revocation endpoint. */ - private async revokeToken(token: string): Promise { + private async revokeToken( + token: string, + tokenTypeHint: "access_token" | "refresh_token" = "refresh_token", + ): Promise { const { axiosInstance, metadata, registration } = await this.prepareOAuthOperation( this.deploymentUrl, this.storedTokens?.access_token, ); - if (!metadata.revocation_endpoint) { - this.logger.warn( - "Server does not support token revocation (no revocation_endpoint)", - ); - return; - } + const revocationEndpoint = + metadata.revocation_endpoint || `${metadata.issuer}/oauth2/revoke`; this.logger.info("Revoking refresh token"); @@ -606,21 +640,17 @@ export class OAuthSessionManager implements vscode.Disposable { token, client_id: registration.client_id, client_secret: registration.client_secret, - token_type_hint: "refresh_token", + token_type_hint: tokenTypeHint, }; const revocationRequest = toUrlSearchParams(params); try { - await axiosInstance.post( - metadata.revocation_endpoint, - revocationRequest, - { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, + await axiosInstance.post(revocationEndpoint, revocationRequest, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", }, - ); + }); this.logger.info("Token revocation successful"); } catch (error) { @@ -633,7 +663,9 @@ export class OAuthSessionManager implements vscode.Disposable { * Logout by revoking tokens and clearing all OAuth data. */ async logout(): Promise { - this.refreshScheduler.stop(); + if (!this.isLoggedInWithOAuth()) { + return; + } // Revoke refresh token (which also invalidates access token per RFC 7009) if (this.storedTokens?.refresh_token) { @@ -650,29 +682,46 @@ export class OAuthSessionManager implements vscode.Disposable { } /** - * Check if currently logged in with OAuth. - * Returns true only if valid OAuth tokens exist for the current deployment. + * Returns true if (valid or invalid) OAuth tokens exist for the current deployment. */ - async isLoggedInWithOAuth(): Promise { - const tokens = await this.secretsManager.getOAuthTokens(); - if (!tokens) { - return false; - } + isLoggedInWithOAuth(): boolean { + return this.storedTokens !== undefined; + } - return this.deploymentUrl === tokens.deployment_url; + /** + * Show a modal dialog to the user when OAuth re-authentication is required. + * This is called when the refresh token is invalid or the client credentials are invalid. + */ + async showReAuthenticationModal(error: OAuthError): Promise { + const errorMessage = + error.description || + "Your session is no longer valid. This could be due to token expiration or revocation."; + + // Log out first to clear invalid tokens + await vscode.commands.executeCommand("coder.logout"); + + const action = await this.vscodeProposed.window.showErrorMessage( + `Authentication Error`, + { modal: true, useCustom: true, detail: errorMessage }, + "Log in again", + ); + + if (action === "Log in again") { + await vscode.commands.executeCommand("coder.login"); + } } /** * Clears all in-memory state and rejects any pending operations. */ dispose(): void { - this.refreshScheduler.stop(); - if (this.pendingAuthReject) { this.pendingAuthReject(new Error("OAuth session manager disposed")); } this.clearPendingAuth(); this.storedTokens = undefined; + this.refreshInProgress = false; + this.lastRefreshAttempt = 0; this.logger.debug("OAuth session manager disposed"); } diff --git a/src/oauth/tokenRefreshScheduler.ts b/src/oauth/tokenRefreshScheduler.ts deleted file mode 100644 index 3eeabb9e..00000000 --- a/src/oauth/tokenRefreshScheduler.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { StoredOAuthTokens } from "../core/secretsManager"; -import type { Logger } from "../logging/logger"; - -// Token refresh timing constant -const TOKEN_REFRESH_THRESHOLD_MS = 20 * 60 * 1000; - -/** - * Manages automatic token refresh scheduling. - * Calculates optimal refresh timing and triggers refresh callbacks. - */ -export class OAuthTokenRefreshScheduler { - private refreshTimer: NodeJS.Timeout | undefined; - - constructor( - private readonly refreshCallback: () => Promise, - private readonly logger: Logger, - ) {} - - /** - * Schedule automatic token refresh based on token expiry. - */ - schedule(tokens: StoredOAuthTokens): void { - this.stop(); - - if (!tokens.refresh_token) { - this.logger.debug("No refresh token available, skipping timer setup"); - return; - } - - const now = Date.now(); - const timeUntilRefresh = - tokens.expiry_timestamp - TOKEN_REFRESH_THRESHOLD_MS - now; - - if (timeUntilRefresh <= 0) { - this.logger.info("Token needs immediate refresh"); - this.refreshCallback().catch((error) => { - this.logger.error("Immediate token refresh failed:", error); - }); - return; - } - - this.refreshTimer = setTimeout(() => { - this.logger.debug("Token refresh timer fired, refreshing token..."); - this.refreshCallback().catch((error) => { - this.logger.error("Scheduled token refresh failed:", error); - }); - }, timeUntilRefresh); - - this.logger.debug("Token refresh timer scheduled", { - fires_at: new Date(now + timeUntilRefresh).toISOString(), - fires_in_seconds: Math.round(timeUntilRefresh / 1000), - }); - } - - /** - * Stop the background token refresh timer. - */ - stop(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = undefined; - this.logger.debug("Token refresh timer stopped"); - } - } -} From 639df18e4cb61228652e189431a558dcc5663adc Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 30 Oct 2025 18:50:47 +0300 Subject: [PATCH 11/11] Allow callback handling in multiple VS Code windows --- src/core/secretsManager.ts | 40 ++++++++++++ src/extension.ts | 2 +- src/oauth/sessionManager.ts | 120 +++++++++++++----------------------- 3 files changed, 85 insertions(+), 77 deletions(-) diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index d16292f1..cdda9c72 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -13,11 +13,19 @@ const OAUTH_CLIENT_REGISTRATION_KEY = "oauthClientRegistration"; const OAUTH_TOKENS_KEY = "oauthTokens"; +const OAUTH_CALLBACK_KEY = "coder.oauthCallback"; + export type StoredOAuthTokens = Omit & { expiry_timestamp: number; deployment_url: string; }; +interface OAuthCallbackData { + state: string; + code: string | null; + error: string | null; +} + export enum AuthAction { LOGIN, LOGOUT, @@ -163,4 +171,36 @@ export class SecretsManager { } return undefined; } + + /** + * Write an OAuth callback result to secrets storage. + * Used for cross-window communication when OAuth callback arrives in a different window. + */ + public async setOAuthCallback(data: OAuthCallbackData): Promise { + await this.secrets.store(OAUTH_CALLBACK_KEY, JSON.stringify(data)); + } + + /** + * Listen for OAuth callback results from any VS Code window. + * The listener receives the state parameter, code (if success), and error (if failed). + */ + public onDidChangeOAuthCallback( + listener: (data: OAuthCallbackData) => void, + ): Disposable { + return this.secrets.onDidChange(async (e) => { + if (e.key !== OAUTH_CALLBACK_KEY) { + return; + } + + try { + const data = await this.secrets.get(OAUTH_CALLBACK_KEY); + if (data) { + const parsed = JSON.parse(data) as OAuthCallbackData; + listener(parsed); + } + } catch { + // Ignore parse errors + } + }); + } } diff --git a/src/extension.ts b/src/extension.ts index 79fce060..8af7583e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -163,7 +163,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const code = params.get("code"); const state = params.get("state"); const error = params.get("error"); - oauthSessionManager.handleCallback(code, state, error); + await oauthSessionManager.handleCallback(code, state, error); return; } diff --git a/src/oauth/sessionManager.ts b/src/oauth/sessionManager.ts index 77fcec63..04733b5f 100644 --- a/src/oauth/sessionManager.ts +++ b/src/oauth/sessionManager.ts @@ -64,13 +64,7 @@ export class OAuthSessionManager implements vscode.Disposable { private refreshInProgress = false; private lastRefreshAttempt = 0; - // Pending authorization flow state - private pendingAuthResolve: - | ((value: { code: string; verifier: string }) => void) - | undefined; private pendingAuthReject: ((reason: Error) => void) | undefined; - private expectedState: string | undefined; - private pendingVerifier: string | undefined; /** * Create and initialize a new OAuth session manager. @@ -370,12 +364,12 @@ export class OAuthSessionManager implements vscode.Disposable { challenge, ); - return new Promise<{ code: string; verifier: string }>( + const callbackPromise = new Promise<{ code: string; verifier: string }>( (resolve, reject) => { const timeoutMins = 5; - const timeout = setTimeout( + const timeoutHandle = setTimeout( () => { - this.clearPendingAuth(); + cleanup(); reject( new Error(`OAuth flow timed out after ${timeoutMins} minutes`), ); @@ -383,93 +377,67 @@ export class OAuthSessionManager implements vscode.Disposable { timeoutMins * 60 * 1000, ); - const clearPromise = () => { - clearTimeout(timeout); - this.clearPendingAuth(); - }; - - this.pendingAuthResolve = (result) => { - clearPromise(); - resolve(result); - }; - - this.pendingAuthReject = (error) => { - clearPromise(); - reject(error); - }; + const listener = this.secretsManager.onDidChangeOAuthCallback( + ({ state: callbackState, code, error }) => { + if (callbackState !== state) { + return; + } - this.expectedState = state; - this.pendingVerifier = verifier; + cleanup(); - vscode.env.openExternal(vscode.Uri.parse(authUrl)).then( - () => {}, - (error) => { - if (error instanceof Error) { - this.pendingAuthReject?.(error); + if (error) { + reject(new Error(`OAuth error: ${error}`)); + } else if (code) { + resolve({ code, verifier }); } else { - this.pendingAuthReject?.(new Error("Failed to open browser")); + reject(new Error("No authorization code received")); } }, ); + + const cleanup = () => { + clearTimeout(timeoutHandle); + listener.dispose(); + }; + + this.pendingAuthReject = (error) => { + cleanup(); + reject(error); + }; }, ); - } - /** - * Clear pending authorization flow state. - */ - private clearPendingAuth(): void { - this.pendingAuthResolve = undefined; - this.pendingAuthReject = undefined; - this.expectedState = undefined; - this.pendingVerifier = undefined; + try { + await vscode.env.openExternal(vscode.Uri.parse(authUrl)); + } catch (error) { + throw error instanceof Error + ? error + : new Error("Failed to open browser"); + } + + return callbackPromise; } /** * Handle OAuth callback from browser redirect. - * Validates state and resolves pending authorization promise. - * - * // TODO this has to work across windows! + * Writes the callback result to secrets storage, triggering the waiting window to proceed. */ - handleCallback( + async handleCallback( code: string | null, state: string | null, error: string | null, - ): void { - if (!this.pendingAuthResolve || !this.pendingAuthReject) { - this.logger.warn("Received OAuth callback but no pending auth flow"); - return; - } - - if (error) { - this.pendingAuthReject(new Error(`OAuth error: ${error}`)); - return; - } - - if (!code) { - this.pendingAuthReject(new Error("No authorization code received")); - return; - } - + ): Promise { if (!state) { - this.pendingAuthReject(new Error("No state received")); - return; - } - - if (state !== this.expectedState) { - this.pendingAuthReject( - new Error("State mismatch - possible CSRF attack"), - ); + this.logger.warn("Received OAuth callback with no state parameter"); return; } - const verifier = this.pendingVerifier; - if (!verifier) { - this.pendingAuthReject(new Error("No PKCE verifier found")); - return; + try { + await this.secretsManager.setOAuthCallback({ state, code, error }); + this.logger.debug("OAuth callback processed successfully"); + } catch (err) { + this.logger.error("Failed to process OAuth callback:", err); } - - this.pendingAuthResolve({ code, verifier }); } /** @@ -712,13 +680,13 @@ export class OAuthSessionManager implements vscode.Disposable { } /** - * Clears all in-memory state and rejects any pending operations. + * Clears all in-memory state. */ dispose(): void { if (this.pendingAuthReject) { this.pendingAuthReject(new Error("OAuth session manager disposed")); } - this.clearPendingAuth(); + this.pendingAuthReject = undefined; this.storedTokens = undefined; this.refreshInProgress = false; this.lastRefreshAttempt = 0;