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 5abeb026..676d539c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,6 +19,8 @@ import { type SecretsManager } from "./core/secretsManager"; import { CertificateError } from "./error"; import { getGlobalFlags } from "./globalFlags"; import { type Logger } from "./logging/logger"; +import { OAuthMetadataClient } from "./oauth/metadataClient"; +import { type OAuthSessionManager } from "./oauth/sessionManager"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; import { AgentTreeItem, @@ -26,6 +28,8 @@ import { WorkspaceTreeItem, } from "./workspace/workspacesProvider"; +type AuthMethod = "oauth" | "legacy"; + export class Commands { private readonly vscodeProposed: typeof vscode; private readonly logger: Logger; @@ -48,6 +52,7 @@ export class Commands { public constructor( serviceContainer: ServiceContainer, private readonly restClient: Api, + private readonly oauthSessionManager: OAuthSessionManager, ) { this.vscodeProposed = serviceContainer.getVsCodeProposed(); this.logger = serviceContainer.getLogger(); @@ -205,10 +210,10 @@ export class Commands { // 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; - + 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; + const res = await this.maybeAskToken(url, args?.token, autoLogin); if (!res) { return; // The user aborted, or unable to auth. @@ -228,7 +233,7 @@ export class Commands { // These contexts control various menu items and the sidebar. this.contextManager.set("coder.authenticated", true); - if (res.user.roles.find((role) => role.name === "owner")) { + if (res.user.roles.some((role) => role.name === "owner")) { this.contextManager.set("coder.isOwner", true); } @@ -291,6 +296,68 @@ 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(client); + } else if (choice === "legacy") { + const initialToken = + token || (await this.secretsManager.getSessionToken()); + return this.loginWithToken(client, initialToken); + } + + return null; // User aborted. + } + + 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( + 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`)); @@ -303,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) { @@ -335,12 +402,40 @@ 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( + client: CoderApi, + ): Promise<{ user: User; token: string } | null> { + try { + this.logger.info("Starting OAuth authentication"); + + const tokenResponse = await this.oauthSessionManager.login(client); + + // Validate token by fetching user + client.setSessionToken(tokenResponse.access_token); + const user = await client.getAuthenticatedUser(); + + 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; + } } /** @@ -377,6 +472,7 @@ export class Commands { // Sanity check; command should not be available if no url. throw new Error("You are not logged in"); } + await this.forceLogout(); } @@ -385,6 +481,12 @@ export class Commands { return; } this.logger.info("Logging out"); + + // 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. this.restClient.setHost(""); @@ -501,7 +603,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. @@ -547,7 +649,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 ?? ""); @@ -555,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}`, { @@ -646,7 +735,7 @@ export class Commands { workspaceAgent, ); - const hostPath = localWorkspaceFolder ? localWorkspaceFolder : undefined; + const hostPath = localWorkspaceFolder || undefined; const configFile = hostPath && localConfigFile ? { @@ -748,7 +837,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 94827b15..cdda9c72 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -1,9 +1,31 @@ +import { + type TokenResponse, + type ClientRegistrationResponse, +} from "../oauth/types"; + import type { SecretStorage, Disposable } from "vscode"; const SESSION_TOKEN_KEY = "sessionToken"; const LOGIN_STATE_KEY = "loginState"; +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, @@ -16,11 +38,13 @@ 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 { + public async setSessionToken( + sessionToken: string | undefined, + ): Promise { + if (sessionToken) { await this.secrets.store(SESSION_TOKEN_KEY, sessionToken); + } else { + await this.secrets.delete(SESSION_TOKEN_KEY); } } @@ -70,4 +94,113 @@ 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. + */ + public async setOAuthClientRegistration( + registration: ClientRegistrationResponse | undefined, + ): Promise { + if (registration) { + await this.secrets.store( + OAUTH_CLIENT_REGISTRATION_KEY, + JSON.stringify(registration), + ); + } else { + await this.secrets.delete(OAUTH_CLIENT_REGISTRATION_KEY); + } + } + + /** + * Get OAuth client registration data. + */ + public async getOAuthClientRegistration(): Promise< + ClientRegistrationResponse | undefined + > { + try { + const stringifiedResponse = await this.secrets.get( + OAUTH_CLIENT_REGISTRATION_KEY, + ); + if (stringifiedResponse) { + return JSON.parse(stringifiedResponse) as ClientRegistrationResponse; + } + } catch { + // Do nothing + } + 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; + } + + /** + * 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 aba94cfe..8af7583e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,6 +12,8 @@ import { Commands } from "./commands"; import { ServiceContainer } from "./core/container"; import { AuthAction } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; +import { OAuthSessionManager } from "./oauth/sessionManager"; +import { CALLBACK_PATH } from "./oauth/utils"; import { Remote } from "./remote/remote"; import { toSafeHost } from "./util"; import { @@ -68,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( @@ -121,11 +133,41 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.subscriptions, ); + // 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"); + return; + } + + output.debug("Session token changed, syncing state"); + + const url = mementoManager.getUrl(); + if (url) { + const cliManager = serviceContainer.getCliManager(); + // TODO label might not match the one in remote? + await cliManager.configure(toSafeHost(url), url, token); + output.debug("Updated CLI config with new token"); + } + }), + ); + // 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"); + await oauthSessionManager.handleCallback(code, state, error); + return; + } + + const cliManager = serviceContainer.getCliManager(); if (uri.path === "/open") { const owner = params.get("owner"); const workspace = params.get("workspace"); @@ -275,7 +317,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, oauthSessionManager); ctx.subscriptions.push( vscode.commands.registerCommand( "coder.login", @@ -369,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 new file mode 100644 index 00000000..98568525 --- /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.debug("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/sessionManager.ts b/src/oauth/sessionManager.ts new file mode 100644 index 00000000..04733b5f --- /dev/null +++ b/src/oauth/sessionManager.ts @@ -0,0 +1,696 @@ +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 { + CALLBACK_PATH, + generatePKCE, + generateState, + toUrlSearchParams, +} from "./utils"; + +import type { SecretsManager, StoredOAuthTokens } from "../core/secretsManager"; +import type { Logger } from "../logging/logger"; + +import type { OAuthError } from "./errors"; +import type { + 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 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. + */ +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 storedTokens: StoredOAuthTokens | undefined; + private refreshInProgress = false; + private lastRefreshAttempt = 0; + + private pendingAuthReject: ((reason: Error) => void) | undefined; + + /** + * Create and initialize a new OAuth session manager. + */ + static async create( + deploymentUrl: string, + container: ServiceContainer, + context: vscode.ExtensionContext, + ): Promise { + const manager = new OAuthSessionManager( + deploymentUrl, + container.getSecretsManager(), + container.getLogger(), + container.getVsCodeProposed(), + context.extension.id, + ); + await manager.loadTokens(); + return manager; + } + + private constructor( + private deploymentUrl: string, + private readonly secretsManager: SecretsManager, + private readonly logger: Logger, + private readonly vscodeProposed: typeof vscode, + private readonly extensionId: string, + ) {} + + /** + * Load stored tokens from storage. + * Validates that tokens belong to the current deployment URL. + */ + private async loadTokens(): Promise { + const tokens = await this.secretsManager.getOAuthTokens(); + if (!tokens) { + return; + } + + 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.clearTokenState(); + return; + } + this.deploymentUrl = tokens.deployment_url; + + 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 for ${tokens.deployment_url}`); + } + + /** + * Clear stale data when tokens don't match current deployment. + */ + private async clearTokenState(): Promise { + this.storedTokens = undefined; + this.refreshInProgress = false; + this.lastRefreshAttempt = 0; + await this.secretsManager.setOAuthTokens(undefined); + await this.secretsManager.setOAuthClientRegistration(undefined); + } + + /** + * Check if granted scopes cover all required scopes. + * Supports wildcard scopes like "workspace:*". + */ + private hasRequiredScopes(grantedScope: string | undefined): boolean { + if (!grantedScope) { + // TODO server always returns empty scopes + return true; + } + + 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}`; + } + + /** + * 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 + */ + 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 && this.deploymentUrl !== baseUrl) { + this.logger.info("Deployment URL changed, clearing cached state", { + old: this.deploymentUrl, + new: baseUrl, + }); + await this.clearTokenState(); + this.deploymentUrl = baseUrl; + } + + 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; + } + + /** + * 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. + */ + private async startAuthorization( + metadata: OAuthServerMetadata, + registration: ClientRegistrationResponse, + ): Promise<{ code: string; verifier: string }> { + const state = generateState(); + const { verifier, challenge } = generatePKCE(); + + const authUrl = this.buildAuthorizationUrl( + metadata, + registration.client_id, + state, + challenge, + ); + + const callbackPromise = new Promise<{ code: string; verifier: string }>( + (resolve, reject) => { + const timeoutMins = 5; + const timeoutHandle = setTimeout( + () => { + cleanup(); + reject( + new Error(`OAuth flow timed out after ${timeoutMins} minutes`), + ); + }, + timeoutMins * 60 * 1000, + ); + + const listener = this.secretsManager.onDidChangeOAuthCallback( + ({ state: callbackState, code, error }) => { + if (callbackState !== state) { + return; + } + + cleanup(); + + if (error) { + reject(new Error(`OAuth error: ${error}`)); + } else if (code) { + resolve({ code, verifier }); + } else { + reject(new Error("No authorization code received")); + } + }, + ); + + const cleanup = () => { + clearTimeout(timeoutHandle); + listener.dispose(); + }; + + this.pendingAuthReject = (error) => { + cleanup(); + reject(error); + }; + }, + ); + + 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. + * Writes the callback result to secrets storage, triggering the waiting window to proceed. + */ + async handleCallback( + code: string | null, + state: string | null, + error: string | null, + ): Promise { + if (!state) { + this.logger.warn("Received OAuth callback with no state parameter"); + 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); + } + } + + /** + * Exchange authorization code for access token. + */ + 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 = { + 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 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. + * Uses a mutex to prevent concurrent refresh attempts. + */ + 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"); + } + + this.refreshInProgress = true; + this.lastRefreshAttempt = Date.now(); + + try { + const { axiosInstance, metadata, registration } = + await this.prepareOAuthOperation( + this.deploymentUrl, + this.storedTokens.access_token, + ); + + 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 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; + } finally { + this.refreshInProgress = false; + } + } + + /** + * Save token response to storage. + * 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) + 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; + } + + 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, + tokenTypeHint: "access_token" | "refresh_token" = "refresh_token", + ): Promise { + const { axiosInstance, metadata, registration } = + await this.prepareOAuthOperation( + this.deploymentUrl, + this.storedTokens?.access_token, + ); + + const revocationEndpoint = + metadata.revocation_endpoint || `${metadata.issuer}/oauth2/revoke`; + + this.logger.info("Revoking refresh token"); + + const params: TokenRevocationRequest = { + token, + client_id: registration.client_id, + client_secret: registration.client_secret, + token_type_hint: tokenTypeHint, + }; + + const revocationRequest = toUrlSearchParams(params); + + try { + await axiosInstance.post(revocationEndpoint, 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 { + if (!this.isLoggedInWithOAuth()) { + return; + } + + // 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.clearTokenState(); + + this.logger.info("OAuth logout complete"); + } + + /** + * Returns true if (valid or invalid) OAuth tokens exist for the current deployment. + */ + isLoggedInWithOAuth(): boolean { + return this.storedTokens !== undefined; + } + + /** + * 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. + */ + dispose(): void { + if (this.pendingAuthReject) { + this.pendingAuthReject(new Error("OAuth session manager disposed")); + } + this.pendingAuthReject = undefined; + this.storedTokens = undefined; + this.refreshInProgress = false; + this.lastRefreshAttempt = 0; + + this.logger.debug("OAuth session manager disposed"); + } +} diff --git a/src/oauth/types.ts b/src/oauth/types.ts new file mode 100644 index 00000000..6ecaa0ff --- /dev/null +++ b/src/oauth/types.ts @@ -0,0 +1,163 @@ +// 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: 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; +} + +// 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[]; + 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; + jwks_uri?: string; + response_types_supported: ResponseType[]; + grant_types_supported?: GrantType[]; + code_challenge_methods_supported: CodeChallengeMethod[]; + scopes_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: TokenType; + expires_in?: number; + refresh_token?: string; + scope?: string; +} + +// Authorization Request Parameters (OAuth 2.1) +export interface AuthorizationRequestParams { + client_id: string; + response_type: ResponseType; + redirect_uri: string; + scope?: string; + state: string; + code_challenge: string; + code_challenge_method: CodeChallengeMethod; +} + +// Token Request Parameters - Authorization Code Grant (OAuth 2.1) +export interface TokenRequestParams { + grant_type: "authorization_code"; + code: string; + redirect_uri: string; + client_id: string; + 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; +} diff --git a/src/oauth/utils.ts b/src/oauth/utils.ts new file mode 100644 index 00000000..61beeb50 --- /dev/null +++ b/src/oauth/utils.ts @@ -0,0 +1,42 @@ +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"); +} + +/** + * 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; } };