Skip to content

Commit 49eb9c6

Browse files
committed
Add schema validation using zod + Remove oauth data clear from getStoredTokens()
1 parent 4c6ebb2 commit 49eb9c6

File tree

8 files changed

+273
-104
lines changed

8 files changed

+273
-104
lines changed

src/core/secretsManager.ts

Lines changed: 87 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import { z } from "zod";
2+
3+
import { DeploymentSchema, type Deployment } from "../deployment/types";
14
import { type Logger } from "../logging/logger";
25
import { type OAuth2ClientRegistrationResponse } from "../oauth/types";
36
import { toSafeHost } from "../util";
47

58
import type { Memento, SecretStorage, Disposable } from "vscode";
69

7-
import type { Deployment } from "../deployment/types";
8-
910
// Each deployment has its own key to ensure atomic operations (multiple windows
1011
// writing to a shared key could drop data) and to receive proper VS Code events.
1112
const SESSION_KEY_PREFIX = "coder.session.";
@@ -22,39 +23,50 @@ const DEFAULT_MAX_DEPLOYMENTS = 10;
2223

2324
const LEGACY_SESSION_TOKEN_KEY = "sessionToken";
2425

25-
export interface CurrentDeploymentState {
26-
deployment: Deployment | null;
27-
}
26+
const CurrentDeploymentStateSchema = z.object({
27+
deployment: DeploymentSchema.nullable(),
28+
});
29+
30+
export type CurrentDeploymentState = z.infer<
31+
typeof CurrentDeploymentStateSchema
32+
>;
2833

2934
/**
3035
* OAuth token data stored alongside session auth.
3136
* When present, indicates the session is authenticated via OAuth.
3237
*/
33-
export interface OAuthTokenData {
34-
token_type: "Bearer";
35-
refresh_token?: string;
36-
scope?: string;
37-
expiry_timestamp: number;
38-
}
38+
const OAuthTokenDataSchema = z.object({
39+
refresh_token: z.string().optional(),
40+
scope: z.string().optional(),
41+
expiry_timestamp: z.number(),
42+
});
43+
44+
export type OAuthTokenData = z.infer<typeof OAuthTokenDataSchema>;
3945

40-
export interface SessionAuth {
41-
url: string;
42-
token: string;
46+
const SessionAuthSchema = z.object({
47+
url: z.string(),
48+
token: z.string(),
4349
/** If present, this session uses OAuth authentication */
44-
oauth?: OAuthTokenData;
45-
}
50+
oauth: OAuthTokenDataSchema.optional(),
51+
});
52+
53+
export type SessionAuth = z.infer<typeof SessionAuthSchema>;
4654

4755
// Tracks when a deployment was last accessed for LRU pruning.
48-
interface DeploymentUsage {
49-
safeHostname: string;
50-
lastAccessedAt: string;
51-
}
56+
const DeploymentUsageSchema = z.object({
57+
safeHostname: z.string(),
58+
lastAccessedAt: z.string(),
59+
});
5260

53-
interface OAuthCallbackData {
54-
state: string;
55-
code: string | null;
56-
error: string | null;
57-
}
61+
type DeploymentUsage = z.infer<typeof DeploymentUsageSchema>;
62+
63+
const OAuthCallbackDataSchema = z.object({
64+
state: z.string(),
65+
code: z.string().nullable(),
66+
error: z.string().nullable(),
67+
});
68+
69+
type OAuthCallbackData = z.infer<typeof OAuthCallbackDataSchema>;
5870

5971
export class SecretsManager {
6072
constructor(
@@ -107,17 +119,18 @@ export class SecretsManager {
107119
public async setCurrentDeployment(
108120
deployment: Deployment | undefined,
109121
): Promise<void> {
110-
const state: CurrentDeploymentState & { timestamp: string } = {
111-
// Extract the necessary fields before serializing
112-
deployment: deployment
113-
? {
114-
url: deployment?.url,
115-
safeHostname: deployment?.safeHostname,
116-
}
117-
: null,
122+
const state = CurrentDeploymentStateSchema.parse({
123+
deployment: deployment ?? null,
124+
});
125+
// Add timestamp for cross-window change detection
126+
const stateWithTimestamp = {
127+
...state,
118128
timestamp: new Date().toISOString(),
119129
};
120-
await this.secrets.store(CURRENT_DEPLOYMENT_KEY, JSON.stringify(state));
130+
await this.secrets.store(
131+
CURRENT_DEPLOYMENT_KEY,
132+
JSON.stringify(stateWithTimestamp),
133+
);
121134
}
122135

123136
/**
@@ -129,8 +142,9 @@ export class SecretsManager {
129142
if (!data) {
130143
return null;
131144
}
132-
const parsed = JSON.parse(data) as CurrentDeploymentState;
133-
return parsed.deployment;
145+
const parsed: unknown = JSON.parse(data);
146+
const result = CurrentDeploymentStateSchema.safeParse(parsed);
147+
return result.success ? result.data.deployment : null;
134148
} catch {
135149
return null;
136150
}
@@ -181,22 +195,26 @@ export class SecretsManager {
181195
});
182196
}
183197

184-
public getSessionAuth(
198+
public async getSessionAuth(
185199
safeHostname: string,
186200
): Promise<SessionAuth | undefined> {
187-
return this.getSecret<SessionAuth>(SESSION_KEY_PREFIX, safeHostname);
201+
const data = await this.getSecret<unknown>(
202+
SESSION_KEY_PREFIX,
203+
safeHostname,
204+
);
205+
if (!data) {
206+
return undefined;
207+
}
208+
const result = SessionAuthSchema.safeParse(data);
209+
return result.success ? result.data : undefined;
188210
}
189211

190212
public async setSessionAuth(
191213
safeHostname: string,
192214
auth: SessionAuth,
193215
): Promise<void> {
194-
// Extract relevant fields before serializing
195-
const state: SessionAuth = {
196-
url: auth.url,
197-
token: auth.token,
198-
...(auth.oauth && { oauth: auth.oauth }),
199-
};
216+
// Parse through schema to strip any extra fields
217+
const state = SessionAuthSchema.parse(auth);
200218
await this.setSecret(SESSION_KEY_PREFIX, safeHostname, state);
201219
}
202220

@@ -214,10 +232,11 @@ export class SecretsManager {
214232
): Promise<void> {
215233
const usage = this.getDeploymentUsage();
216234
const filtered = usage.filter((u) => u.safeHostname !== safeHostname);
217-
filtered.unshift({
235+
const newEntry = DeploymentUsageSchema.parse({
218236
safeHostname,
219237
lastAccessedAt: new Date().toISOString(),
220238
});
239+
filtered.unshift(newEntry);
221240

222241
const toKeep = filtered.slice(0, maxCount);
223242
const toRemove = filtered.slice(maxCount);
@@ -253,7 +272,12 @@ export class SecretsManager {
253272
* Get the full deployment usage list with access timestamps.
254273
*/
255274
private getDeploymentUsage(): DeploymentUsage[] {
256-
return this.memento.get<DeploymentUsage[]>(DEPLOYMENT_USAGE_KEY) ?? [];
275+
const data = this.memento.get<unknown>(DEPLOYMENT_USAGE_KEY);
276+
if (!data) {
277+
return [];
278+
}
279+
const result = z.array(DeploymentUsageSchema).safeParse(data);
280+
return result.success ? result.data : [];
257281
}
258282

259283
/**
@@ -294,7 +318,8 @@ export class SecretsManager {
294318
* Used for cross-window communication when OAuth callback arrives in a different window.
295319
*/
296320
public async setOAuthCallback(data: OAuthCallbackData): Promise<void> {
297-
await this.secrets.store(OAUTH_CALLBACK_KEY, JSON.stringify(data));
321+
const parsed = OAuthCallbackDataSchema.parse(data);
322+
await this.secrets.store(OAUTH_CALLBACK_KEY, JSON.stringify(parsed));
298323
}
299324

300325
/**
@@ -309,20 +334,27 @@ export class SecretsManager {
309334
return;
310335
}
311336

312-
let parsed: OAuthCallbackData;
337+
const raw = await this.secrets.get(OAUTH_CALLBACK_KEY);
338+
if (!raw) {
339+
return;
340+
}
341+
342+
let parsed: unknown;
313343
try {
314-
const data = await this.secrets.get(OAUTH_CALLBACK_KEY);
315-
if (!data) {
316-
return;
317-
}
318-
parsed = JSON.parse(data) as OAuthCallbackData;
344+
parsed = JSON.parse(raw);
319345
} catch (err) {
320-
this.logger.error("Failed to parse OAuth callback data", err);
346+
this.logger.error("Failed to parse OAuth callback JSON", err);
347+
return;
348+
}
349+
350+
const result = OAuthCallbackDataSchema.safeParse(parsed);
351+
if (!result.success) {
352+
this.logger.error("Invalid OAuth callback data shape", result.error);
321353
return;
322354
}
323355

324356
try {
325-
listener(parsed);
357+
listener(result.data);
326358
} catch (err) {
327359
this.logger.error("Error in onDidChangeOAuthCallback listener", err);
328360
}

src/deployment/types.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
import { type User } from "coder/site/src/api/typesGenerated";
1+
import { z } from "zod";
2+
3+
import type { User } from "coder/site/src/api/typesGenerated";
24

35
/**
46
* Represents a Coder deployment with its URL and hostname.
57
* The safeHostname is used as a unique identifier for storing credentials and configuration.
68
* It is derived from the URL hostname (via toSafeHost) or from SSH host parsing.
79
*/
8-
export interface Deployment {
9-
readonly url: string;
10-
readonly safeHostname: string;
11-
}
10+
export const DeploymentSchema = z.object({
11+
url: z.string(),
12+
safeHostname: z.string(),
13+
});
14+
15+
export type Deployment = z.infer<typeof DeploymentSchema>;
1216

1317
/**
1418
* Deployment info with authentication credentials.

src/oauth/sessionManager.ts

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { type ServiceContainer } from "../core/container";
55
import {
66
type OAuthTokenData,
77
type SecretsManager,
8-
type SessionAuth,
98
} from "../core/secretsManager";
109
import { type Deployment } from "../deployment/types";
1110
import { type Logger } from "../logging/logger";
@@ -134,19 +133,17 @@ export class OAuthSessionManager implements vscode.Disposable {
134133

135134
// Validate deployment URL matches
136135
if (auth.url !== this.deployment.url) {
137-
this.logger.warn(
138-
"Stored tokens have mismatched deployment URL, clearing OAuth",
139-
{ stored: auth.url, current: this.deployment.url },
140-
);
141-
await this.clearOAuthFromSessionAuth(auth);
136+
this.logger.warn("Stored tokens have mismatched deployment URL", {
137+
stored: auth.url,
138+
current: this.deployment.url,
139+
});
142140
return undefined;
143141
}
144142

145143
if (!this.hasRequiredScopes(auth.oauth.scope)) {
146-
this.logger.warn("Stored tokens have insufficient scopes, clearing", {
144+
this.logger.warn("Stored tokens have insufficient scopes", {
147145
scope: auth.oauth.scope,
148146
});
149-
await this.clearOAuthFromSessionAuth(auth);
150147
return undefined;
151148
}
152149

@@ -156,19 +153,6 @@ export class OAuthSessionManager implements vscode.Disposable {
156153
};
157154
}
158155

159-
/**
160-
* Clear OAuth data from session auth while preserving the session token.
161-
*/
162-
private async clearOAuthFromSessionAuth(auth: SessionAuth): Promise<void> {
163-
if (!this.deployment) {
164-
return;
165-
}
166-
await this.secretsManager.setSessionAuth(this.deployment.safeHostname, {
167-
url: auth.url,
168-
token: auth.token,
169-
});
170-
}
171-
172156
/**
173157
* Clear all refresh-related state: in-flight promise, throttle, timer, and listener.
174158
* Aborts any in-flight refresh request to prevent stale token updates.

src/oauth/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ export function buildOAuthTokenData(
7272
: Date.now() + ACCESS_TOKEN_DEFAULT_EXPIRY_MS;
7373

7474
return {
75-
token_type: tokenResponse.token_type,
7675
refresh_token: tokenResponse.refresh_token,
7776
scope: tokenResponse.scope,
7877
expiry_timestamp: expiryTimestamp,

test/unit/api/authInterceptor.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@ function createTestContext() {
103103
url: TEST_URL,
104104
token: "access-token",
105105
oauth: {
106-
token_type: "Bearer",
107106
refresh_token: "refresh-token",
108107
expiry_timestamp: Date.now() + ONE_HOUR_MS,
109108
},

0 commit comments

Comments
 (0)