Skip to content

Commit 5175117

Browse files
committed
chore: Change to use oauth4webapi instead of simple-oauth2
This adds support for a custom fetch that can use proxies configured with environment variables.
1 parent 02d58c7 commit 5175117

File tree

3 files changed

+106
-152
lines changed

3 files changed

+106
-152
lines changed

package-lock.json

Lines changed: 4 additions & 110 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@
8585
"mongodb-redact": "^1.1.8",
8686
"mongodb-schema": "^12.6.2",
8787
"node-machine-id": "1.1.12",
88+
"oauth4webapi": "^3.6.0",
8889
"openapi-fetch": "^0.14.0",
89-
"simple-oauth2": "^5.1.0",
9090
"yargs-parser": "^22.0.0",
9191
"zod": "^3.25.76"
9292
},

src/common/atlas/apiClient.ts

Lines changed: 101 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import createClient, { Client, Middleware } from "openapi-fetch";
22
import type { FetchOptions } from "openapi-fetch";
3-
import { AccessToken, ClientCredentials } from "simple-oauth2";
43
import { ApiClientError } from "./apiClientError.js";
54
import { paths, operations } from "./openapi.js";
65
import { CommonProperties, TelemetryEvent } from "../../telemetry/types.js";
76
import { packageInfo } from "../packageInfo.js";
87
import logger, { LogId } from "../logger.js";
9-
import { createFetch, useOrCreateAgent } from "@mongodb-js/devtools-proxy-support";
10-
import HTTPS from "https";
8+
import { createFetch } from "@mongodb-js/devtools-proxy-support";
9+
import * as oauth from "oauth4webapi";
1110

1211
const ATLAS_API_VERSION = "2025-03-12";
1312

@@ -22,6 +21,11 @@ export interface ApiClientOptions {
2221
userAgent?: string;
2322
}
2423

24+
interface AccessToken {
25+
access_token: string;
26+
expires_at?: number;
27+
}
28+
2529
export class ApiClient {
2630
private options: {
2731
baseUrl: string;
@@ -36,24 +40,33 @@ export class ApiClient {
3640
useEnvironmentVariableProxies: true,
3741
}) as unknown as typeof fetch;
3842

39-
private static customAgent = useOrCreateAgent({
40-
useEnvironmentVariableProxies: true,
41-
});
42-
4343
private client: Client<paths>;
44-
private oauth2Client?: ClientCredentials;
44+
45+
private oauth2Client?: oauth.Client;
46+
private oauth2Issuer?: oauth.AuthorizationServer;
4547
private accessToken?: AccessToken;
4648

47-
private ensureAgentIsInitialized = async () => {
48-
await ApiClient.customAgent?.initialize?.();
49-
};
49+
public hasCredentials(): boolean {
50+
return !!this.oauth2Client && !!this.oauth2Issuer;
51+
}
52+
53+
private isAccessTokenValid(): boolean {
54+
return !!(
55+
this.accessToken &&
56+
(this.accessToken.expires_at == undefined || this.accessToken.expires_at > Date.now() / 1000)
57+
);
58+
}
5059

5160
private getAccessToken = async () => {
52-
// await this.ensureAgentIsInitialized();
53-
if (this.oauth2Client && (!this.accessToken || this.accessToken.expired())) {
54-
this.accessToken = await this.oauth2Client.getToken({});
61+
if (!this.hasCredentials()) {
62+
return undefined;
5563
}
56-
return this.accessToken?.token.access_token as string | undefined;
64+
65+
if (!this.isAccessTokenValid()) {
66+
this.accessToken = await this.getNewAccessToken();
67+
}
68+
69+
return this.accessToken?.access_token;
5770
};
5871

5972
private authMiddleware: Middleware = {
@@ -90,46 +103,93 @@ export class ApiClient {
90103
},
91104
fetch: ApiClient.customFetch,
92105
});
106+
93107
if (this.options.credentials?.clientId && this.options.credentials?.clientSecret) {
94-
this.oauth2Client = new ClientCredentials({
95-
client: {
96-
id: this.options.credentials.clientId,
97-
secret: this.options.credentials.clientSecret,
98-
},
99-
auth: {
100-
tokenHost: this.options.baseUrl,
101-
tokenPath: "/api/oauth/token",
102-
revokePath: "/api/oauth/revoke",
103-
},
104-
http: {
105-
headers: {
106-
"User-Agent": this.options.userAgent,
107-
},
108-
agent: ApiClient.customAgent,
109-
},
110-
});
108+
this.oauth2Issuer = {
109+
issuer: this.options.baseUrl,
110+
token_endpoint: new URL("/api/oauth/token", this.options.baseUrl).toString(),
111+
revocation_endpoint: new URL("/api/oauth/revoke", this.options.baseUrl).toString(),
112+
token_endpoint_auth_methods_supported: ["client_secret_basic"],
113+
grant_types_supported: ["client_credentials"],
114+
};
115+
116+
this.oauth2Client = {
117+
client_id: this.options.credentials.clientId,
118+
client_secret: this.options.credentials.clientSecret,
119+
};
120+
111121
this.client.use(this.authMiddleware);
112122
}
113123
}
114124

115-
public hasCredentials(): boolean {
116-
return !!this.oauth2Client;
125+
private getOauthClientAuth(): { client: oauth.Client | undefined; clientAuth: oauth.ClientAuth | undefined } {
126+
if (this.options.credentials?.clientId && this.options.credentials.clientSecret) {
127+
const clientSecret = this.options.credentials.clientSecret;
128+
const clientId = this.options.credentials.clientId;
129+
130+
// We are using our own ClientAuth because ClientSecretBasic URL encodes wrongly
131+
// the username and password (for example, encodes `_` which is wrong).
132+
return {
133+
client: { client_id: clientId },
134+
clientAuth: (_as, client, _body, headers) => {
135+
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
136+
headers.set("Authorization", `Basic ${credentials}`);
137+
},
138+
};
139+
}
140+
141+
return { client: undefined, clientAuth: undefined };
142+
}
143+
144+
private async getNewAccessToken(): Promise<AccessToken | undefined> {
145+
if (!this.hasCredentials() || !this.oauth2Issuer) {
146+
return undefined;
147+
}
148+
149+
const { client, clientAuth } = this.getOauthClientAuth();
150+
if (client && clientAuth) {
151+
try {
152+
const response = await oauth.clientCredentialsGrantRequest(
153+
this.oauth2Issuer,
154+
client,
155+
clientAuth,
156+
new URLSearchParams(),
157+
{
158+
[oauth.customFetch]: ApiClient.customFetch,
159+
headers: {
160+
"User-Agent": this.options.userAgent,
161+
},
162+
}
163+
);
164+
165+
const result = await oauth.processClientCredentialsResponse(this.oauth2Issuer, client, response);
166+
this.accessToken = {
167+
access_token: result.access_token,
168+
expires_at: Date.now() / 1000 + (result.expires_in ?? 0),
169+
};
170+
} catch (error: unknown) {
171+
const err = error instanceof Error ? error : new Error(String(error));
172+
logger.error(LogId.atlasConnectFailure, "apiClient", `Failed to request access token: ${err.message}`);
173+
}
174+
return this.accessToken;
175+
}
117176
}
118177

119178
public async validateAccessToken(): Promise<void> {
120179
await this.getAccessToken();
121180
}
122181

123182
public async close(): Promise<void> {
124-
if (this.accessToken) {
125-
try {
126-
await this.accessToken.revoke("access_token");
127-
} catch (error: unknown) {
128-
const err = error instanceof Error ? error : new Error(String(error));
129-
logger.error(LogId.atlasApiRevokeFailure, "apiClient", `Failed to revoke access token: ${err.message}`);
183+
const { client, clientAuth } = this.getOauthClientAuth();
184+
try {
185+
if (this.oauth2Issuer && this.accessToken && client && clientAuth) {
186+
await oauth.revocationRequest(this.oauth2Issuer, client, clientAuth, this.accessToken.access_token);
130187
}
131-
this.accessToken = undefined;
188+
} catch (error: unknown) {
189+
const err = error instanceof Error ? error : new Error(String(error));
190+
logger.error(LogId.atlasApiRevokeFailure, "apiClient", `Failed to revoke access token: ${err.message}`);
132191
}
192+
this.accessToken = undefined;
133193
}
134194

135195
public async getIpInfo(): Promise<{

0 commit comments

Comments
 (0)