Skip to content

Commit 87cd9c1

Browse files
committed
Improve session expiry handling and OAuth token management
- Show "Log In" button when session expires instead of just a message - Keep deployment info after session expiry for easier re-login - Automatically recover session when tokens are refreshed in another window - Validate OAuth scopes to detect permission changes - Consolidate duplicated OAuth constants
1 parent ab2c9fe commit 87cd9c1

20 files changed

+453
-291
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ export class Commands {
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: 41 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,7 @@ 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+
this.updateAuthContexts(deployment.user);
136131
this.refreshWorkspaces();
137132

138133
await this.oauthSessionManager.setDeployment(deployment);
@@ -143,16 +138,32 @@ export class DeploymentManager implements vscode.Disposable {
143138
* Clears the current deployment.
144139
*/
145140
public async clearDeployment(): Promise<void> {
141+
this.suspendSession();
146142
this.#authListenerDisposable?.dispose();
147143
this.#authListenerDisposable = undefined;
148144
this.#deployment = null;
149145

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

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

158169
public dispose(): void {
@@ -163,6 +174,7 @@ export class DeploymentManager implements vscode.Disposable {
163174
/**
164175
* Register auth listener for the current deployment.
165176
* Updates credentials when they change (token refresh, cross-window sync).
177+
* Also handles recovery from suspended session state.
166178
*/
167179
private registerAuthListener(): void {
168180
if (!this.#deployment) {
@@ -182,7 +194,18 @@ export class DeploymentManager implements vscode.Disposable {
182194
}
183195

184196
if (auth) {
185-
this.client.setCredentials(auth.url, auth.token);
197+
if (this.contextManager.get("coder.authenticated")) {
198+
this.client.setCredentials(auth.url, auth.token);
199+
} else {
200+
this.logger.debug(
201+
"Token updated after session suspended, recovering",
202+
);
203+
await this.setDeploymentIfValid({
204+
url: auth.url,
205+
safeHostname,
206+
token: auth.token,
207+
});
208+
}
186209
} else {
187210
await this.clearDeployment();
188211
}
@@ -210,8 +233,7 @@ export class DeploymentManager implements vscode.Disposable {
210233
/**
211234
* Update authentication-related contexts.
212235
*/
213-
private updateAuthContexts(): void {
214-
const user = this.#deployment?.user;
236+
private updateAuthContexts(user: User | undefined): void {
215237
this.contextManager.set("coder.authenticated", Boolean(user));
216238
const isOwner = user?.roles.some((r) => r.name === "owner") ?? false;
217239
this.contextManager.set("coder.isOwner", isOwner);

src/extension.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,25 @@ 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 = async (): Promise<void> => {
75+
deploymentManager.suspendSession();
76+
const action = await vscode.window.showWarningMessage(
77+
"Session expired. You have been signed out.",
78+
"Log In",
79+
);
80+
if (action === "Log In") {
81+
await commands.login({
82+
url: deploymentManager.getCurrentDeployment()?.url,
83+
});
84+
}
85+
};
86+
87+
// Create OAuth session manager - callback handles background refresh failures
7488
const oauthSessionManager = OAuthSessionManager.create(
7589
deployment,
7690
serviceContainer,
91+
handleAuthFailure,
7792
);
7893
ctx.subscriptions.push(oauthSessionManager);
7994

@@ -94,11 +109,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
94109
output,
95110
oauthSessionManager,
96111
secretsManager,
97-
() => {
98-
void vscode.window.showWarningMessage(
99-
"Session expired. Please log in again using the Coder sidebar.",
100-
);
101-
return Promise.resolve(false);
112+
async () => {
113+
await handleAuthFailure();
114+
return false;
102115
},
103116
);
104117
ctx.subscriptions.push(authInterceptor);
@@ -324,7 +337,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
324337
process.env.CODER_URL?.trim();
325338
if (defaultUrl) {
326339
commands.login({ url: defaultUrl, autoLogin: true }).catch((error) => {
327-
output.error("Failed to auto-login", error);
340+
output.error("Auto-login failed", error);
328341
});
329342
}
330343
}

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)