Skip to content

Commit 29d1a13

Browse files
committed
WIP refactoring
1 parent 8ce9114 commit 29d1a13

File tree

11 files changed

+1124
-939
lines changed

11 files changed

+1124
-939
lines changed

src/commands.ts

Lines changed: 146 additions & 173 deletions
Large diffs are not rendered by default.

src/core/mementoManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export class MementoManager {
1313
* If the URL is falsey, then remove it as the last used URL and do not touch
1414
* the history.
1515
*/
16-
public async setUrl(url?: string): Promise<void> {
16+
public async setUrl(url: string | undefined): Promise<void> {
1717
await this.memento.update("url", url);
1818
if (url) {
1919
const history = this.withUrlHistory(url);

src/core/secretsManager.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const OAUTH_TOKENS_KEY = "oauthTokens";
1515

1616
export type StoredOAuthTokens = Omit<TokenResponse, "expires_in"> & {
1717
expiry_timestamp: number;
18+
deployment_url: string;
1819
};
1920

2021
export enum AuthAction {
@@ -29,7 +30,9 @@ export class SecretsManager {
2930
/**
3031
* Set or unset the last used token.
3132
*/
32-
public async setSessionToken(sessionToken?: string): Promise<void> {
33+
public async setSessionToken(
34+
sessionToken: string | undefined,
35+
): Promise<void> {
3336
if (sessionToken) {
3437
await this.secrets.store(SESSION_TOKEN_KEY, sessionToken);
3538
} else {
@@ -160,11 +163,4 @@ export class SecretsManager {
160163
}
161164
return undefined;
162165
}
163-
164-
/**
165-
* Clear OAuth token data.
166-
*/
167-
public async clearOAuthTokens(): Promise<void> {
168-
await this.secrets.delete(OAUTH_TOKENS_KEY);
169-
}
170166
}

src/extension.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Commands } from "./commands";
1212
import { ServiceContainer } from "./core/container";
1313
import { AuthAction } from "./core/secretsManager";
1414
import { CertificateError, getErrorDetail } from "./error";
15-
import { activateCoderOAuth } from "./oauth/oauthHelper";
15+
import { OAuthSessionManager } from "./oauth/sessionManager";
1616
import { CALLBACK_PATH } from "./oauth/utils";
1717
import { Remote } from "./remote/remote";
1818
import { toSafeHost } from "./util";
@@ -123,13 +123,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
123123
ctx.subscriptions,
124124
);
125125

126-
const oauthHelper = await activateCoderOAuth(
126+
const oauthSessionManager = await OAuthSessionManager.create(
127127
url || "",
128128
secretsManager,
129129
output,
130130
ctx,
131131
);
132-
ctx.subscriptions.push(oauthHelper);
132+
ctx.subscriptions.push(oauthSessionManager);
133133

134134
// Listen for session token changes and sync state across all components
135135
ctx.subscriptions.push(
@@ -162,7 +162,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
162162
const code = params.get("code");
163163
const state = params.get("state");
164164
const error = params.get("error");
165-
oauthHelper.handleCallback(code, state, error);
165+
oauthSessionManager.handleCallback(code, state, error);
166166
return;
167167
}
168168

@@ -316,7 +316,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
316316

317317
// Register globally available commands. Many of these have visibility
318318
// controlled by contexts, see `when` in the package.json.
319-
const commands = new Commands(serviceContainer, client, oauthHelper);
319+
const commands = new Commands(serviceContainer, client, oauthSessionManager);
320320
ctx.subscriptions.push(
321321
vscode.commands.registerCommand(
322322
"coder.login",

src/oauth/clientRegistry.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { AxiosInstance } from "axios";
2+
3+
import type { SecretsManager } from "../core/secretsManager";
4+
import type { Logger } from "../logging/logger";
5+
6+
import type {
7+
ClientRegistrationRequest,
8+
ClientRegistrationResponse,
9+
OAuthServerMetadata,
10+
} from "./types";
11+
12+
const AUTH_GRANT_TYPE = "authorization_code" as const;
13+
const RESPONSE_TYPE = "code" as const;
14+
const OAUTH_METHOD = "client_secret_post" as const;
15+
const CLIENT_NAME = "VS Code Coder Extension";
16+
17+
/**
18+
* Manages OAuth client registration and persistence.
19+
*/
20+
export class OAuthClientRegistry {
21+
private registration: ClientRegistrationResponse | undefined;
22+
23+
constructor(
24+
private readonly axiosInstance: AxiosInstance,
25+
private readonly secretsManager: SecretsManager,
26+
private readonly logger: Logger,
27+
) {}
28+
29+
/**
30+
* Load existing client registration from secure storage.
31+
* Should be called during initialization.
32+
*/
33+
async load(): Promise<void> {
34+
const registration = await this.secretsManager.getOAuthClientRegistration();
35+
if (registration) {
36+
this.registration = registration;
37+
this.logger.info("Loaded existing OAuth client:", registration.client_id);
38+
}
39+
}
40+
41+
/**
42+
* Get the current client registration if one exists.
43+
*/
44+
get(): ClientRegistrationResponse | undefined {
45+
return this.registration;
46+
}
47+
48+
/**
49+
* Register a new OAuth client or return existing if still valid.
50+
* Re-registers if redirect URI has changed.
51+
*/
52+
async register(
53+
metadata: OAuthServerMetadata,
54+
redirectUri: string,
55+
): Promise<ClientRegistrationResponse> {
56+
if (this.registration?.client_id) {
57+
if (this.registration.redirect_uris.includes(redirectUri)) {
58+
this.logger.info(
59+
"Using existing client registration:",
60+
this.registration.client_id,
61+
);
62+
return this.registration;
63+
}
64+
this.logger.info("Redirect URI changed, re-registering client");
65+
}
66+
67+
if (!metadata.registration_endpoint) {
68+
throw new Error("Server does not support dynamic client registration");
69+
}
70+
71+
// "web" type since VS Code Secrets API allows secure client_secret storage (confidential client)
72+
const registrationRequest: ClientRegistrationRequest = {
73+
redirect_uris: [redirectUri],
74+
application_type: "web",
75+
grant_types: [AUTH_GRANT_TYPE],
76+
response_types: [RESPONSE_TYPE],
77+
client_name: CLIENT_NAME,
78+
token_endpoint_auth_method: OAUTH_METHOD,
79+
};
80+
81+
const response = await this.axiosInstance.post<ClientRegistrationResponse>(
82+
metadata.registration_endpoint,
83+
registrationRequest,
84+
);
85+
86+
await this.save(response.data);
87+
88+
return response.data;
89+
}
90+
91+
/**
92+
* Save client registration to secure storage.
93+
*/
94+
private async save(registration: ClientRegistrationResponse): Promise<void> {
95+
await this.secretsManager.setOAuthClientRegistration(registration);
96+
this.registration = registration;
97+
this.logger.info(
98+
"Saved OAuth client registration:",
99+
registration.client_id,
100+
);
101+
}
102+
103+
/**
104+
* Clear the current client registration from memory and storage.
105+
*/
106+
async clear(): Promise<void> {
107+
await this.secretsManager.setOAuthClientRegistration(undefined);
108+
this.registration = undefined;
109+
this.logger.info("Cleared OAuth client registration");
110+
}
111+
}

src/oauth/metadataClient.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type { AxiosInstance } from "axios";
2+
3+
import type { Logger } from "../logging/logger";
4+
5+
import type { OAuthServerMetadata } from "./types";
6+
7+
const OAUTH_DISCOVERY_ENDPOINT = "/.well-known/oauth-authorization-server";
8+
9+
const AUTH_GRANT_TYPE = "authorization_code" as const;
10+
const REFRESH_GRANT_TYPE = "refresh_token" as const;
11+
const RESPONSE_TYPE = "code" as const;
12+
const OAUTH_METHOD = "client_secret_post" as const;
13+
const PKCE_CHALLENGE_METHOD = "S256" as const;
14+
15+
const REQUIRED_GRANT_TYPES = [AUTH_GRANT_TYPE, REFRESH_GRANT_TYPE] as const;
16+
17+
/**
18+
* Client for discovering and validating OAuth server metadata.
19+
*/
20+
export class OAuthMetadataClient {
21+
constructor(
22+
private readonly axiosInstance: AxiosInstance,
23+
private readonly logger: Logger,
24+
) {}
25+
26+
/**
27+
* Check if a server supports OAuth by attempting to fetch the well-known endpoint.
28+
*/
29+
async checkOAuthSupport(): Promise<boolean> {
30+
try {
31+
await this.axiosInstance.get(OAUTH_DISCOVERY_ENDPOINT);
32+
this.logger.debug("Server supports OAuth");
33+
return true;
34+
} catch (error) {
35+
this.logger.debug("Server does not support OAuth:", error);
36+
return false;
37+
}
38+
}
39+
40+
/**
41+
* Fetch and validate OAuth server metadata.
42+
* Throws detailed errors if server doesn't meet OAuth 2.1 requirements.
43+
*/
44+
async getMetadata(): Promise<OAuthServerMetadata> {
45+
this.logger.info("Discovering OAuth endpoints...");
46+
47+
const response = await this.axiosInstance.get<OAuthServerMetadata>(
48+
OAUTH_DISCOVERY_ENDPOINT,
49+
);
50+
51+
const metadata = response.data;
52+
53+
this.validateRequiredEndpoints(metadata);
54+
this.validateGrantTypes(metadata);
55+
this.validateResponseTypes(metadata);
56+
this.validateAuthMethods(metadata);
57+
this.validatePKCEMethods(metadata);
58+
59+
this.logger.debug("OAuth endpoints discovered:", {
60+
authorization: metadata.authorization_endpoint,
61+
token: metadata.token_endpoint,
62+
registration: metadata.registration_endpoint,
63+
revocation: metadata.revocation_endpoint,
64+
});
65+
66+
return metadata;
67+
}
68+
69+
private validateRequiredEndpoints(metadata: OAuthServerMetadata): void {
70+
if (
71+
!metadata.authorization_endpoint ||
72+
!metadata.token_endpoint ||
73+
!metadata.issuer
74+
) {
75+
throw new Error(
76+
"OAuth server metadata missing required endpoints: " +
77+
JSON.stringify(metadata),
78+
);
79+
}
80+
}
81+
82+
private validateGrantTypes(metadata: OAuthServerMetadata): void {
83+
if (
84+
!includesAllTypes(metadata.grant_types_supported, REQUIRED_GRANT_TYPES)
85+
) {
86+
throw new Error(
87+
`Server does not support required grant types: ${REQUIRED_GRANT_TYPES.join(", ")}. Supported: ${metadata.grant_types_supported?.join(", ") || "none"}`,
88+
);
89+
}
90+
}
91+
92+
private validateResponseTypes(metadata: OAuthServerMetadata): void {
93+
if (!includesAllTypes(metadata.response_types_supported, [RESPONSE_TYPE])) {
94+
throw new Error(
95+
`Server does not support required response type: ${RESPONSE_TYPE}. Supported: ${metadata.response_types_supported?.join(", ") || "none"}`,
96+
);
97+
}
98+
}
99+
100+
private validateAuthMethods(metadata: OAuthServerMetadata): void {
101+
if (
102+
!includesAllTypes(metadata.token_endpoint_auth_methods_supported, [
103+
OAUTH_METHOD,
104+
])
105+
) {
106+
throw new Error(
107+
`Server does not support required auth method: ${OAUTH_METHOD}. Supported: ${metadata.token_endpoint_auth_methods_supported?.join(", ") || "none"}`,
108+
);
109+
}
110+
}
111+
112+
private validatePKCEMethods(metadata: OAuthServerMetadata): void {
113+
if (
114+
!includesAllTypes(metadata.code_challenge_methods_supported, [
115+
PKCE_CHALLENGE_METHOD,
116+
])
117+
) {
118+
throw new Error(
119+
`Server does not support required PKCE method: ${PKCE_CHALLENGE_METHOD}. Supported: ${metadata.code_challenge_methods_supported?.join(", ") || "none"}`,
120+
);
121+
}
122+
}
123+
}
124+
125+
/**
126+
* Check if an array includes all required types.
127+
* If the array is undefined, returns true (server didn't specify, assume all allowed).
128+
*/
129+
function includesAllTypes(
130+
arr: string[] | undefined,
131+
requiredTypes: readonly string[],
132+
): boolean {
133+
if (arr === undefined) {
134+
return true;
135+
}
136+
return requiredTypes.every((type) => arr.includes(type));
137+
}

0 commit comments

Comments
 (0)