Skip to content

Commit b025a08

Browse files
authored
Improve OAuth session expiry handling (#730)
- Improves the user experience when sessions expire by showing a "Log In" button - Keeps deployment context after expiry so users don't have to re-enter the URL - Adds automatic session recovery when tokens are updated from another window - Adds OAuth scope validation to detect when required permissions change Fixes #723
1 parent bd700b6 commit b025a08

20 files changed

+484
-296
lines changed

src/api/authInterceptor.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type AxiosError, isAxiosError } from "axios";
22

3+
import { OAuthError } from "../oauth/errors";
34
import { toSafeHost } from "../util";
45

56
import type * as vscode from "vscode";
@@ -27,6 +28,7 @@ export type AuthRequiredHandler = (hostname: string) => Promise<boolean>;
2728
*/
2829
export class AuthInterceptor implements vscode.Disposable {
2930
private readonly interceptorId: number;
31+
private authRequiredPromise: Promise<boolean> | null = null;
3032

3133
constructor(
3234
private readonly client: CoderApi,
@@ -82,13 +84,21 @@ export class AuthInterceptor implements vscode.Disposable {
8284
this.logger.debug("Token refresh successful, retrying request");
8385
return this.retryRequest(error, newTokens.access_token);
8486
} catch (refreshError) {
85-
this.logger.error("OAuth refresh failed:", refreshError);
87+
if (refreshError instanceof OAuthError) {
88+
const msg = `Token refresh failed: ${refreshError.message}`;
89+
if (refreshError.requiresReAuth) {
90+
this.logger.warn(msg);
91+
} else {
92+
this.logger.error(msg);
93+
}
94+
} else {
95+
this.logger.error("Token refresh failed:", refreshError);
96+
}
8697
}
8798
}
8899

89100
if (this.onAuthRequired) {
90-
this.logger.debug("Triggering interactive re-authentication");
91-
const success = await this.onAuthRequired(hostname);
101+
const success = await this.executeAuthRequired(hostname);
92102
if (success) {
93103
const auth = await this.secretsManager.getSessionAuth(hostname);
94104
if (auth) {
@@ -101,6 +111,28 @@ export class AuthInterceptor implements vscode.Disposable {
101111
throw error;
102112
}
103113

114+
/**
115+
* Execute auth required callback with deduplication.
116+
* Multiple concurrent 401s will share the same promise.
117+
*/
118+
private async executeAuthRequired(hostname: string): Promise<boolean> {
119+
if (this.authRequiredPromise) {
120+
this.logger.debug(
121+
"Auth callback already in progress, waiting for result",
122+
);
123+
return this.authRequiredPromise;
124+
}
125+
126+
this.logger.debug("Triggering re-authentication");
127+
this.authRequiredPromise = this.onAuthRequired!(hostname);
128+
129+
try {
130+
return await this.authRequiredPromise;
131+
} finally {
132+
this.authRequiredPromise = null;
133+
}
134+
}
135+
104136
private retryRequest(error: AxiosError, token: string): Promise<unknown> {
105137
if (!error.config) {
106138
throw error;

src/commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,12 @@ export class Commands {
209209

210210
await this.deploymentManager.clearDeployment();
211211

212-
void vscode.window
212+
vscode.window
213213
.showInformationMessage("You've been logged out of Coder!", "Login")
214214
.then((action) => {
215215
if (action === "Login") {
216216
this.login().catch((error) => {
217-
this.logger.error("Failed to login", error);
217+
this.logger.error("Login failed", error);
218218
});
219219
}
220220
});

src/core/secretsManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export type CurrentDeploymentState = z.infer<
3838
*/
3939
const OAuthTokenDataSchema = z.object({
4040
refresh_token: z.string().optional(),
41-
scope: z.string().optional(),
41+
scope: z.string(),
4242
expiry_timestamp: z.number(),
4343
});
4444

src/deployment/deploymentManager.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@ import { type Deployment, type DeploymentWithAuth } from "./types";
1212
import type { User } from "coder/site/src/api/typesGenerated";
1313
import type * as vscode from "vscode";
1414

15-
/**
16-
* Internal state type that allows mutation of user property.
17-
*/
18-
type DeploymentWithUser = Deployment & { user: User };
19-
2015
/**
2116
* Manages deployment state for the extension.
2217
*
@@ -35,7 +30,7 @@ export class DeploymentManager implements vscode.Disposable {
3530
private readonly contextManager: ContextManager;
3631
private readonly logger: Logger;
3732

38-
#deployment: DeploymentWithUser | null = null;
33+
#deployment: Deployment | null = null;
3934
#authListenerDisposable: vscode.Disposable | undefined;
4035
#crossWindowSyncDisposable: vscode.Disposable | undefined;
4136

@@ -78,7 +73,7 @@ export class DeploymentManager implements vscode.Disposable {
7873
* Check if we have an authenticated deployment (does not guarantee that the current auth data is valid).
7974
*/
8075
public isAuthenticated(): boolean {
81-
return this.#deployment !== null;
76+
return this.contextManager.get("coder.authenticated");
8277
}
8378

8479
/**
@@ -89,10 +84,10 @@ export class DeploymentManager implements vscode.Disposable {
8984
public async setDeploymentIfValid(
9085
deployment: Deployment & { token?: string },
9186
): Promise<boolean> {
92-
const auth = await this.secretsManager.getSessionAuth(
93-
deployment.safeHostname,
94-
);
95-
const token = deployment.token ?? auth?.token;
87+
const token =
88+
deployment.token ??
89+
(await this.secretsManager.getSessionAuth(deployment.safeHostname))
90+
?.token;
9691
const tempClient = CoderApi.create(deployment.url, token, this.logger);
9792

9893
try {
@@ -132,7 +127,8 @@ export class DeploymentManager implements vscode.Disposable {
132127
// Register auth listener before setDeployment so background token refresh
133128
// can update client credentials via the listener
134129
this.registerAuthListener();
135-
this.updateAuthContexts();
130+
// Contexts must be set before refresh (providers check isAuthenticated)
131+
this.updateAuthContexts(deployment.user);
136132
this.refreshWorkspaces();
137133

138134
await this.oauthSessionManager.setDeployment(deployment);
@@ -143,16 +139,32 @@ export class DeploymentManager implements vscode.Disposable {
143139
* Clears the current deployment.
144140
*/
145141
public async clearDeployment(): Promise<void> {
142+
this.suspendSession();
146143
this.#authListenerDisposable?.dispose();
147144
this.#authListenerDisposable = undefined;
148145
this.#deployment = null;
149146

150-
this.client.setCredentials(undefined, undefined);
147+
await this.secretsManager.setCurrentDeployment(undefined);
148+
}
149+
150+
/**
151+
* Suspend session: shows logged-out state but keeps deployment for easy re-login.
152+
* Auth listener remains active so recovery can happen automatically if tokens update.
153+
*/
154+
public suspendSession(): void {
151155
this.oauthSessionManager.clearDeployment();
152-
this.updateAuthContexts();
153-
this.refreshWorkspaces();
156+
this.client.setCredentials(undefined, undefined);
157+
this.updateAuthContexts(undefined);
158+
this.clearWorkspaces();
159+
}
154160

155-
await this.secretsManager.setCurrentDeployment(undefined);
161+
/**
162+
* Clear all workspace providers without fetching.
163+
*/
164+
private clearWorkspaces(): void {
165+
for (const provider of this.workspaceProviders) {
166+
provider.clear();
167+
}
156168
}
157169

158170
public dispose(): void {
@@ -163,6 +175,7 @@ export class DeploymentManager implements vscode.Disposable {
163175
/**
164176
* Register auth listener for the current deployment.
165177
* Updates credentials when they change (token refresh, cross-window sync).
178+
* Also handles recovery from suspended session state.
166179
*/
167180
private registerAuthListener(): void {
168181
if (!this.#deployment) {
@@ -182,7 +195,18 @@ export class DeploymentManager implements vscode.Disposable {
182195
}
183196

184197
if (auth) {
185-
this.client.setCredentials(auth.url, auth.token);
198+
if (this.isAuthenticated()) {
199+
this.client.setCredentials(auth.url, auth.token);
200+
} else {
201+
this.logger.debug(
202+
"Token updated after session suspended, recovering",
203+
);
204+
await this.setDeploymentIfValid({
205+
url: auth.url,
206+
safeHostname,
207+
token: auth.token,
208+
});
209+
}
186210
} else {
187211
await this.clearDeployment();
188212
}
@@ -210,8 +234,7 @@ export class DeploymentManager implements vscode.Disposable {
210234
/**
211235
* Update authentication-related contexts.
212236
*/
213-
private updateAuthContexts(): void {
214-
const user = this.#deployment?.user;
237+
private updateAuthContexts(user: User | undefined): void {
215238
this.contextManager.set("coder.authenticated", Boolean(user));
216239
const isOwner = user?.roles.some((r) => r.name === "owner") ?? false;
217240
this.contextManager.set("coder.isOwner", isOwner);

src/extension.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,33 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
7070

7171
const deployment = await secretsManager.getCurrentDeployment();
7272

73-
// Create OAuth session manager with login coordinator
73+
// Shared handler for auth failures (used by interceptor + session manager)
74+
const handleAuthFailure = (): Promise<void> => {
75+
deploymentManager.suspendSession();
76+
vscode.window
77+
.showWarningMessage(
78+
"Session expired. You have been signed out.",
79+
"Log In",
80+
)
81+
.then(async (action) => {
82+
if (action === "Log In") {
83+
try {
84+
await commands.login({
85+
url: deploymentManager.getCurrentDeployment()?.url,
86+
});
87+
} catch (err) {
88+
output.error("Login failed", err);
89+
}
90+
}
91+
});
92+
return Promise.resolve();
93+
};
94+
95+
// Create OAuth session manager - callback handles background refresh failures
7496
const oauthSessionManager = OAuthSessionManager.create(
7597
deployment,
7698
serviceContainer,
99+
handleAuthFailure,
77100
);
78101
ctx.subscriptions.push(oauthSessionManager);
79102

@@ -94,19 +117,20 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
94117
output,
95118
oauthSessionManager,
96119
secretsManager,
97-
() => {
98-
void vscode.window.showWarningMessage(
99-
"Session expired. Please log in again using the Coder sidebar.",
100-
);
101-
return Promise.resolve(false);
120+
async () => {
121+
await handleAuthFailure();
122+
return false;
102123
},
103124
);
104125
ctx.subscriptions.push(authInterceptor);
105126

127+
const isAuthenticated = () => contextManager.get("coder.authenticated");
128+
106129
const myWorkspacesProvider = new WorkspaceProvider(
107130
WorkspaceQuery.Mine,
108131
client,
109132
output,
133+
isAuthenticated,
110134
5,
111135
);
112136
ctx.subscriptions.push(myWorkspacesProvider);
@@ -115,6 +139,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
115139
WorkspaceQuery.All,
116140
client,
117141
output,
142+
isAuthenticated,
118143
);
119144
ctx.subscriptions.push(allWorkspacesProvider);
120145

@@ -295,11 +320,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
295320
output.info(`Initializing deployment: ${deployment.url}`);
296321
deploymentManager
297322
.setDeploymentIfValid(deployment)
323+
// Failure is logged internally
298324
.then((success) => {
299325
if (success) {
300326
output.info("Deployment authenticated and set");
301-
} else {
302-
output.info("Failed to authenticate, deployment not set");
303327
}
304328
})
305329
.catch((error: unknown) => {
@@ -324,7 +348,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
324348
process.env.CODER_URL?.trim();
325349
if (defaultUrl) {
326350
commands.login({ url: defaultUrl, autoLogin: true }).catch((error) => {
327-
output.error("Failed to auto-login", error);
351+
output.error("Auto-login failed", error);
328352
});
329353
}
330354
}

src/oauth/authorizer.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { type Logger } from "../logging/logger";
88

99
import {
1010
AUTH_GRANT_TYPE,
11+
DEFAULT_OAUTH_SCOPES,
1112
PKCE_CHALLENGE_METHOD,
1213
RESPONSE_TYPE,
1314
TOKEN_ENDPOINT_AUTH_METHOD,
@@ -29,19 +30,6 @@ import type {
2930
User,
3031
} from "coder/site/src/api/typesGenerated";
3132

32-
/**
33-
* Minimal scopes required by the VS Code extension.
34-
*/
35-
const DEFAULT_OAUTH_SCOPES = [
36-
"workspace:read",
37-
"workspace:update",
38-
"workspace:start",
39-
"workspace:ssh",
40-
"workspace:application_connect",
41-
"template:read",
42-
"user:read_personal",
43-
].join(" ");
44-
4533
/**
4634
* Handles the OAuth authorization code flow for authenticating with Coder deployments.
4735
* Encapsulates client registration, PKCE challenge, and token exchange.

src/oauth/constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22
export const AUTH_GRANT_TYPE = "authorization_code";
33
export const REFRESH_GRANT_TYPE = "refresh_token";
44

5+
// Minimal scopes required by the VS Code extension
6+
export const DEFAULT_OAUTH_SCOPES = [
7+
"workspace:read",
8+
"workspace:update",
9+
"workspace:start",
10+
"workspace:ssh",
11+
"workspace:application_connect",
12+
"template:read",
13+
"user:read_personal",
14+
].join(" ");
15+
516
// OAuth 2.1 Response Types
617
export const RESPONSE_TYPE = "code";
718

src/oauth/errors.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ export class OAuthError extends Error {
3434
);
3535
this.name = "OAuthError";
3636
}
37+
38+
/**
39+
* Returns true if this error indicates the user needs to re-authenticate.
40+
*/
41+
get requiresReAuth(): boolean {
42+
return (
43+
this.errorCode === "invalid_grant" || this.errorCode === "invalid_client"
44+
);
45+
}
3746
}
3847

3948
export function parseOAuthError(error: unknown): OAuthError | null {
@@ -57,9 +66,3 @@ function isOAuth2Error(data: unknown): data is OAuth2Error {
5766
typeof data.error === "string"
5867
);
5968
}
60-
61-
export function requiresReAuthentication(error: OAuthError): boolean {
62-
return (
63-
error.errorCode === "invalid_grant" || error.errorCode === "invalid_client"
64-
);
65-
}

src/oauth/metadataClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type { Logger } from "../logging/logger";
1818

1919
const OAUTH_DISCOVERY_ENDPOINT = "/.well-known/oauth-authorization-server";
2020

21-
const REQUIRED_GRANT_TYPES: readonly string[] = [
21+
const REQUIRED_GRANT_TYPES: readonly OAuth2ProviderGrantType[] = [
2222
AUTH_GRANT_TYPE,
2323
REFRESH_GRANT_TYPE,
2424
];

0 commit comments

Comments
 (0)