diff --git a/jest.config.js b/jest.config.js index a4de3e960..5ac58f70d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,7 @@ module.exports = { testEnvironment: 'node', testMatch: ['/src/**/*.test.ts'], + setupFilesAfterEnv: ['/src/plugins/service-mongo-atlas/__tests__/jest.setup.atlas.ts'], transform: { '^.+.tsx?$': ['ts-jest', {}], }, diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index a55a2f9d0..7b5ed7593 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -132,6 +132,7 @@ "Delete database \"{databaseId}\" and its contents?": "Delete database \"{databaseId}\" and its contents?", "Delete selected document(s)": "Delete selected document(s)", "Deleting...": "Deleting...", + "Digest credentials not found": "Digest credentials not found", "Disable TLS/SSL (Not recommended)": "Disable TLS/SSL (Not recommended)", "Disable TLS/SSL checks when connecting.": "Disable TLS/SSL checks when connecting.", "Do not rely on case to distinguish between databases. For example, you cannot use two databases with names like, salesData and SalesData.": "Do not rely on case to distinguish between databases. For example, you cannot use two databases with names like, salesData and SalesData.", @@ -195,9 +196,12 @@ "Failed to access Azure Databases VS Code Extension storage for migration: {error}": "Failed to access Azure Databases VS Code Extension storage for migration: {error}", "Failed to connect to \"{cluster}\"": "Failed to connect to \"{cluster}\"", "Failed to connect to VM \"{vmName}\"": "Failed to connect to VM \"{vmName}\"", + "Failed to create access list entries for project {0}: {1} {2}": "Failed to create access list entries for project {0}: {1} {2}", "Failed to create Azure management clients: {0}": "Failed to create Azure management clients: {0}", "Failed to create role assignment \"{0}\" for the {2} resource \"{1}\".": "Failed to create role assignment \"{0}\" for the {2} resource \"{1}\".", "Failed to create role assignment(s).": "Failed to create role assignment(s).", + "Failed to delete access list entry {0} from project {1}: {2}": "Failed to delete access list entry {0} from project {1}: {2}", + "Failed to delete access list entry {0} from project {1}: {2} {3}": "Failed to delete access list entry {0} from project {1}: {2} {3}", "Failed to delete documents. Unknown error.": "Failed to delete documents. Unknown error.", "Failed to delete item \"{0}\".": "Failed to delete item \"{0}\".", "Failed to delete secrets for item \"{0}\".": "Failed to delete secrets for item \"{0}\".", @@ -205,9 +209,16 @@ "Failed to extract cluster credentials from the selected node.": "Failed to extract cluster credentials from the selected node.", "Failed to extract the connection string from the selected account.": "Failed to extract the connection string from the selected account.", "Failed to find commandId on generic tree item.": "Failed to find commandId on generic tree item.", + "Failed to get access list for project {0}: {1} {2}": "Failed to get access list for project {0}: {1} {2}", + "Failed to get cluster {0} in project {1}: {2} {3}": "Failed to get cluster {0} in project {1}: {2} {3}", "Failed to get public IP": "Failed to get public IP", "Failed to initialize Azure management clients": "Failed to initialize Azure management clients", + "Failed to list Atlas projects: {0} {1}": "Failed to list Atlas projects: {0} {1}", + "Failed to list clusters for project {0}: {1} {2}": "Failed to list clusters for project {0}: {1} {2}", + "Failed to list database users for project {0}: {1} {2}": "Failed to list database users for project {0}: {1} {2}", "Failed to obtain Entra ID token.": "Failed to obtain Entra ID token.", + "Failed to obtain OAuth token: {0} {1}": "Failed to obtain OAuth token: {0} {1}", + "Failed to obtain valid OAuth token": "Failed to obtain valid OAuth token", "Failed to parse secrets for key {0}:": "Failed to parse secrets for key {0}:", "Failed to process URI: {0}": "Failed to process URI: {0}", "Failed to rename the connection.": "Failed to rename the connection.", @@ -291,6 +302,8 @@ "New Local Connection": "New Local Connection", "New Local Connection…": "New Local Connection…", "No": "No", + "No Atlas credentials found for organization {0}": "No Atlas credentials found for organization {0}", + "No Atlas OAuth credentials found for organization {0}": "No Atlas OAuth credentials found for organization {0}", "No authentication method selected.": "No authentication method selected.", "No authentication methods available for \"{cluster}\".": "No authentication methods available for \"{cluster}\".", "No Azure subscription found for this tree item.": "No Azure subscription found for this tree item.", @@ -311,6 +324,7 @@ "Not connected to any MongoDB database.": "Not connected to any MongoDB database.", "Note: This confirmation type can be configured in the extension settings.": "Note: This confirmation type can be configured in the extension settings.", "Note: You can disable these URL handling confirmations in the extension settings.": "Note: You can disable these URL handling confirmations in the extension settings.", + "OAuth credentials not found for organization {0}": "OAuth credentials not found for organization {0}", "Open Collection": "Open Collection", "Open installation page": "Open installation page", "Opening DocumentDB connection…": "Opening DocumentDB connection…", @@ -341,6 +355,7 @@ "Remind Me Later": "Remind Me Later", "Rename Connection": "Rename Connection", "Report an issue": "Report an issue", + "Request failed with status {status}: {errorText}": "Request failed with status {status}: {errorText}", "Resource group \"{0}\" already exists in subscription \"{1}\".": "Resource group \"{0}\" already exists in subscription \"{1}\".", "Reusing active connection for \"{cluster}\".": "Reusing active connection for \"{cluster}\".", "Revisit connection details and try again.": "Revisit connection details and try again.", @@ -448,6 +463,7 @@ "Unrecognized node type encountered. Could not parse {constructorCall} as part of {functionCall}": "Unrecognized node type encountered. Could not parse {constructorCall} as part of {functionCall}", "Unrecognized node type encountered. We could not parse {text}": "Unrecognized node type encountered. We could not parse {text}", "Unrecognized token. Token text: {text}": "Unrecognized token. Token text: {text}", + "Unsupported Atlas authentication type: {0}": "Unsupported Atlas authentication type: {0}", "Unsupported authentication mechanism. Only \"Username and Password\" (SCRAM-SHA-256) is supported.": "Unsupported authentication mechanism. Only \"Username and Password\" (SCRAM-SHA-256) is supported.", "Unsupported authentication mechanism. Only SCRAM-SHA-256 (username/password) is supported.": "Unsupported authentication mechanism. Only SCRAM-SHA-256 (username/password) is supported.", "Unsupported authentication method: {0}": "Unsupported authentication method: {0}", diff --git a/package-lock.json b/package-lock.json index d4363c39d..d1e92d477 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "antlr4ts": "^0.5.0-alpha.4", "bson": "~6.10.4", "denque": "~2.1.0", + "digest-fetch": "^3.1.1", "es-toolkit": "~1.39.7", "monaco-editor": "~0.51.0", "mongodb": "~6.17.0", @@ -8049,6 +8050,11 @@ "license": "Apache-2.0", "optional": true }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8633,7 +8639,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": "*" @@ -9138,7 +9143,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": "*" @@ -9593,6 +9597,18 @@ "node": ">=0.3.1" } }, + "node_modules/digest-fetch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-3.1.1.tgz", + "integrity": "sha512-sfMKKDctvDUTljFnqTBeDl/20vZtKKNZ3XQleht2j3Ky1xuQLuvE9lT+Fvp3EZxJHwAk6RXeEEL4DCmKDSd8xA==", + "license": "ISC", + "dependencies": { + "base-64": "^0.1.0", + "js-sha256": "^0.9.0", + "js-sha512": "^0.8.0", + "md5": "^2.3.0" + } + }, "node_modules/discontinuous-range": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", @@ -12356,7 +12372,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true, "license": "MIT" }, "node_modules/is-callable": { @@ -13613,6 +13628,18 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-sha256": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", + "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==", + "license": "MIT" + }, + "node_modules/js-sha512": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha512/-/js-sha512-0.8.0.tgz", + "integrity": "sha512-PWsmefG6Jkodqt+ePTvBZCSMFgN7Clckjd0O7su3I0+BW2QWUTJNzjktHsztGLhncP2h8mcF9V9Y2Ha59pAViQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -14120,7 +14147,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "charenc": "0.0.2", diff --git a/package.json b/package.json index cc15e2f85..640bade8f 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "antlr4ts": "^0.5.0-alpha.4", "bson": "~6.10.4", "denque": "~2.1.0", + "digest-fetch": "^3.1.1", "es-toolkit": "~1.39.7", "monaco-editor": "~0.51.0", "mongodb": "~6.17.0", diff --git a/src/plugins/service-mongo-atlas/__tests__/AtlasAdministrationClient.test.ts b/src/plugins/service-mongo-atlas/__tests__/AtlasAdministrationClient.test.ts new file mode 100644 index 000000000..6ece8f3db --- /dev/null +++ b/src/plugins/service-mongo-atlas/__tests__/AtlasAdministrationClient.test.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AtlasApiResponse } from '../utils/AtlasAdminApiTypes'; +import { AtlasAdministrationClient } from '../utils/AtlasAdministrationClient'; +import { AtlasHttpClient } from '../utils/AtlasHttpClient'; + +function mockJson(data: T): Response { + return { ok: true, status: 200, json: async () => data } as any as Response; +} + +function mockFail(status: number, text: string): Response { + return { ok: false, status, text: async () => text } as any as Response; +} + +describe('AtlasAdministrationClient (Jest)', () => { + const orgId = 'org'; + const projectId = 'proj'; + + let originalGet: typeof AtlasHttpClient.get; + let originalPost: typeof AtlasHttpClient.post; + let originalDelete: typeof AtlasHttpClient.delete; + + beforeEach(() => { + originalGet = AtlasHttpClient.get.bind(AtlasHttpClient); + originalPost = AtlasHttpClient.post.bind(AtlasHttpClient); + originalDelete = AtlasHttpClient.delete.bind(AtlasHttpClient); + }); + + afterEach(() => { + AtlasHttpClient.get = originalGet; + AtlasHttpClient.post = originalPost; + AtlasHttpClient.delete = originalDelete; + }); + + test('listProjects success builds query params', async () => { + const data: AtlasApiResponse = { + results: [{ name: 'p', orgId: orgId, created: '', clusterCount: 0 }], + totalCount: 1, + }; + let calledEndpoint = ''; + AtlasHttpClient.get = (async (_org, endpoint) => { + calledEndpoint = endpoint; + return mockJson(data); + }) as any; + const resp = await AtlasAdministrationClient.listProjects(orgId, { + pageNum: 1, + itemsPerPage: 5, + includeCount: true, + }); + expect(resp.totalCount).toBe(1); + expect(/pageNum=1/.test(calledEndpoint)).toBe(true); + }); + + test('listProjects failure throws', async () => { + AtlasHttpClient.get = (async () => mockFail(500, 'err')) as any; + await expect(AtlasAdministrationClient.listProjects(orgId)).rejects.toThrow(/Failed to list Atlas projects/); + }); + + test('listClusters success', async () => { + const data: AtlasApiResponse = { + results: [ + { + clusterType: 'REPLICASET', + providerSettings: { providerName: 'AWS', regionName: 'us', instanceSizeName: 'M10' }, + stateName: 'IDLE', + }, + ], + totalCount: 1, + }; + AtlasHttpClient.get = (async () => mockJson(data)) as any; + const resp = await AtlasAdministrationClient.listClusters(orgId, projectId); + expect(resp.results.length).toBe(1); + }); + + test('getCluster failure throws', async () => { + AtlasHttpClient.get = (async () => mockFail(404, 'missing')) as any; + await expect(AtlasAdministrationClient.getCluster(orgId, projectId, 'cl')).rejects.toThrow( + /Failed to get cluster/, + ); + }); + + test('listDatabaseUsers failure throws', async () => { + AtlasHttpClient.get = (async () => mockFail(400, 'bad')) as any; + await expect(AtlasAdministrationClient.listDatabaseUsers(orgId, projectId)).rejects.toThrow( + /Failed to list database users/, + ); + }); + + test('getAccessList failure throws', async () => { + AtlasHttpClient.get = (async () => mockFail(401, 'unauth')) as any; + await expect(AtlasAdministrationClient.getAccessList(orgId, projectId)).rejects.toThrow( + /Failed to get access list/, + ); + }); + + test('createAccessListEntries failure throws', async () => { + AtlasHttpClient.post = (async () => mockFail(500, 'boom')) as any; + await expect(AtlasAdministrationClient.createAccessListEntries(orgId, projectId, [])).rejects.toThrow( + /Failed to create access list entries/, + ); + }); + + test('deleteAccessListEntry failure throws', async () => { + AtlasHttpClient.delete = (async () => mockFail(403, 'deny')) as any; + await expect(AtlasAdministrationClient.deleteAccessListEntry(orgId, projectId, '1.1.1.1')).rejects.toThrow( + /Failed to delete access list entry/, + ); + }); +}); diff --git a/src/plugins/service-mongo-atlas/__tests__/AtlasAuthManager.test.ts b/src/plugins/service-mongo-atlas/__tests__/AtlasAuthManager.test.ts new file mode 100644 index 000000000..06866e968 --- /dev/null +++ b/src/plugins/service-mongo-atlas/__tests__/AtlasAuthManager.test.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AtlasAuthManager } from '../utils/AtlasAuthManager'; +import { AtlasCredentialCache } from '../utils/AtlasCredentialCache'; + +type FetchFn = (url: string, init?: any) => Promise; + +describe('AtlasAuthManager (Jest)', () => { + const orgId = 'authOrg'; + const clientId = 'client'; + const clientSecret = 'secret'; + let originalFetch: any; + + beforeEach(() => { + originalFetch = global.fetch; + // ensure we start with a clean slate + // @ts-expect-error override for test + delete global.fetch; + }); + + afterEach(() => { + AtlasCredentialCache.clearAtlasCredentials(orgId); + if (originalFetch) { + global.fetch = originalFetch; + } else { + // @ts-expect-error restore + delete global.fetch; + } + }); + + test('getOAuthBasicAuthHeader encodes credentials', () => { + const hdr = AtlasAuthManager.getOAuthBasicAuthHeader('id', 'sec'); + expect(hdr).toBe('Basic aWQ6c2Vj'); + }); + + test('requestOAuthToken success stores nothing automatically', async () => { + const mockFetch: FetchFn = async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: 'tok', expires_in: 100, token_type: 'Bearer' }), + }); + global.fetch = mockFetch as any; + const resp = await AtlasAuthManager.requestOAuthToken(clientId, clientSecret); + expect(resp.access_token).toBe('tok'); + }); + + test('requestOAuthToken failure throws with status and text', async () => { + const mockFetch: FetchFn = async () => ({ ok: false, status: 400, text: async () => 'bad request' }); + global.fetch = mockFetch as any; + await expect(AtlasAuthManager.requestOAuthToken(clientId, clientSecret)).rejects.toThrow(/400/); + }); + + test('getAuthorizationHeader returns bearer token using cache', async () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, clientId, clientSecret); + AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'cachedToken', 3600); + const hdr = await AtlasAuthManager.getAuthorizationHeader(orgId); + expect(hdr).toBe('Bearer cachedToken'); + }); + + test('getAuthorizationHeader fetches new token when expired', async () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, clientId, clientSecret); + AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'old', -1); + const mockFetch: FetchFn = async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: 'newToken', expires_in: 50, token_type: 'Bearer' }), + }); + global.fetch = mockFetch as any; + const hdr = await AtlasAuthManager.getAuthorizationHeader(orgId); + expect(hdr).toBe('Bearer newToken'); + }); + + test('getAuthorizationHeader undefined when no credentials', async () => { + const hdr = await AtlasAuthManager.getAuthorizationHeader('missing'); + expect(hdr).toBeUndefined(); + }); +}); diff --git a/src/plugins/service-mongo-atlas/__tests__/AtlasCredentialCache.test.ts b/src/plugins/service-mongo-atlas/__tests__/AtlasCredentialCache.test.ts new file mode 100644 index 000000000..2c93eb7e3 --- /dev/null +++ b/src/plugins/service-mongo-atlas/__tests__/AtlasCredentialCache.test.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AtlasCredentialCache } from '../utils/AtlasCredentialCache'; + +describe('AtlasCredentialCache (Jest)', () => { + const orgId = 'OrgOne'; + const orgIdDifferentCase = 'orgone'; + + afterEach(() => { + AtlasCredentialCache.clearAtlasCredentials(orgId); + }); + + test('set and get OAuth credentials (case insensitive key)', () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'clientId', 'clientSecret'); + const creds = AtlasCredentialCache.getAtlasCredentials(orgIdDifferentCase); + expect(creds).toBeDefined(); + expect(creds?.authType).toBe('oauth'); + expect(creds?.oauth?.clientId).toBe('clientId'); + }); + + test('set and get Digest credentials', () => { + AtlasCredentialCache.setAtlasDigestCredentials(orgId, 'public', 'private'); + const creds = AtlasCredentialCache.getAtlasCredentials(orgId); + expect(creds?.authType).toBe('digest'); + expect(creds?.digest?.publicKey).toBe('public'); + }); + + test('update token caches expiry and value', () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'client', 'secret'); + AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'token123', 120); + const creds = AtlasCredentialCache.getAtlasCredentials(orgId)!; + expect(creds.oauth?.accessToken).toBe('token123'); + expect((creds.oauth?.tokenExpiry ?? 0) > Date.now()).toBe(true); + expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(true); + }); + + test('token validity false when missing token or expired', () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'client', 'secret'); + expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(false); + AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'token123', -1); + expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(false); + }); + + test('clear credentials removes entry', () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'client', 'secret'); + expect(AtlasCredentialCache.getAtlasCredentials(orgId)).toBeDefined(); + AtlasCredentialCache.clearAtlasCredentials(orgId); + expect(AtlasCredentialCache.getAtlasCredentials(orgId)).toBeUndefined(); + }); + + test('updateAtlasOAuthToken throws if oauth creds missing', () => { + expect(() => AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'tkn')).toThrow(/No Atlas OAuth credentials/); + }); +}); diff --git a/src/plugins/service-mongo-atlas/__tests__/AtlasHttpClient.test.ts b/src/plugins/service-mongo-atlas/__tests__/AtlasHttpClient.test.ts new file mode 100644 index 000000000..e9b4a5230 --- /dev/null +++ b/src/plugins/service-mongo-atlas/__tests__/AtlasHttpClient.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AtlasAuthManager } from '../utils/AtlasAuthManager'; +import { AtlasCredentialCache } from '../utils/AtlasCredentialCache'; +import { AtlasHttpClient } from '../utils/AtlasHttpClient'; + +type FetchFn = (url: string, init?: any) => Promise; + +describe('AtlasHttpClient (Jest)', () => { + const orgId = 'org-http'; + let originalFetch: any; + let originalGetAuthHeader: typeof AtlasAuthManager.getAuthorizationHeader; + + beforeEach(() => { + originalFetch = global.fetch; + originalGetAuthHeader = AtlasAuthManager.getAuthorizationHeader.bind(AtlasAuthManager); + // @ts-expect-error override + delete global.fetch; + AtlasCredentialCache.clearAtlasCredentials(orgId); + }); + + afterEach(() => { + if (originalFetch) { + global.fetch = originalFetch; + } else { + // @ts-expect-error restore + delete global.fetch; + } + AtlasAuthManager.getAuthorizationHeader = originalGetAuthHeader as any; + }); + + test('throws when no credentials', async () => { + await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(/No Atlas credentials/); + }); + + test('uses OAuth flow and sets Authorization header', async () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'cid', 'sec'); + let called = false; + AtlasAuthManager.getAuthorizationHeader = (async () => { + called = true; + return 'Bearer tokenX'; + }) as any; + const fetchSpyCalls: any[] = []; + const fetchSpy: FetchFn = async (_url, init) => { + fetchSpyCalls.push([_url, init]); + return { ok: true, status: 200, json: async () => ({ ok: true }) } as any; + }; + global.fetch = fetchSpy as any; + + await AtlasHttpClient.get(orgId, '/groups'); + expect(called).toBe(true); + const headers = fetchSpyCalls[0][1].headers; + expect(headers.Authorization).toBe('Bearer tokenX'); + }); + + test('oauth flow throws when missing bearer', async () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'cid', 'sec'); + AtlasAuthManager.getAuthorizationHeader = (async () => 'Invalid') as any; + await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(/Failed to obtain valid OAuth token/); + }); + + test('digest flow uses digest-fetch client and throws on non-ok', async () => { + AtlasCredentialCache.setAtlasDigestCredentials(orgId, 'pub', 'priv'); + global.fetch = (async () => ({ ok: false, status: 401, text: async () => 'Unauthorized' })) as any; + await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(); + }); +}); diff --git a/src/plugins/service-mongo-atlas/__tests__/jest.setup.atlas.ts b/src/plugins/service-mongo-atlas/__tests__/jest.setup.atlas.ts new file mode 100644 index 000000000..82ecffa5a --- /dev/null +++ b/src/plugins/service-mongo-atlas/__tests__/jest.setup.atlas.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** Global Jest setup for Atlas service tests */ + +// Mock digest-fetch (ESM) with a simple constructor returning an object containing fetch. +// Allows tests to override behavior by redefining the returned fetch if needed. +jest.mock('digest-fetch', () => { + return function MockDigestClient() { + return { + fetch: (_url: string, _init?: Record) => + ({ + ok: false, + status: 401, + text: () => Promise.resolve('Unauthorized'), + }) as Response, + }; + }; +}); diff --git a/src/plugins/service-mongo-atlas/utils/AtlasAdminApiTypes.ts b/src/plugins/service-mongo-atlas/utils/AtlasAdminApiTypes.ts new file mode 100644 index 000000000..4c8ee1a70 --- /dev/null +++ b/src/plugins/service-mongo-atlas/utils/AtlasAdminApiTypes.ts @@ -0,0 +1,247 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Atlas project (group) information + */ +export type AtlasProject = { + id?: string; + name: string; + orgId: string; + created: string; + clusterCount: number; + links?: Array<{ + href: string; + rel: string; + }>; + regionUsageRestrictions?: 'COMMERCIAL_FEDRAMP_REGIONS_ONLY' | 'GOV_REGIONS_ONLY'; + tags?: Array<{ + key: string; + value: string; + }>; + withDefaultAlertsSettings?: boolean; +}; + +/** + * Atlas cluster information + */ +export type AtlasCluster = { + id?: string; + name?: string; + groupId?: string; + mongoDBMajorVersion?: string; + mongoDBVersion?: string; + clusterType: 'REPLICASET' | 'SHARDED' | 'GEOSHARDED'; + providerSettings: { + providerName: string; + regionName: string; + instanceSizeName: string; + }; + connectionStrings?: { + awsPrivateLink?: object; + awsPrivateLinkSrv?: object; + standard?: string; + standardSrv?: string; + private?: string; + privateEndpoint?: Array<{ + connectionString?: string; + endpoints?: Array<{ + endpointId?: string; + providerName?: 'AWS' | 'AZURE' | 'GCP'; + region?: string; + }>; + srvConnectionString?: string; + srvShardOptimizedConnectionString?: string; + type?: 'MONGOD' | 'MONGOS'; + }>; + privateSrv?: string; + }; + stateName: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'DELETED' | 'REPAIRING'; + createDate?: string; // DATE-TIME + links?: Array<{ + href: string; + rel: string; + }>; + acceptDataRisksAndForceReplicaSetReconfig?: string; // DATE-TIME + advancedConfiguration?: { + customOpensslCipherConfigTls12?: Array; + minimumEnabledTlsProtocol?: 'TLS1_0' | 'TLS1_1' | 'TLS1_2'; + tlsCipherConfigMode?: 'CUSTOM' | 'DEFAULT'; + }; + backupEnabled?: boolean; + biConnector?: { + enabled?: boolean; + readPreference?: 'PRIMARY' | 'SECONDARY' | 'ANALYTICS'; + }; + configServerManagementMode?: 'ATLAS_MANAGED' | 'FIXED_TO_DEDICATED'; + configServerType?: 'DEDICATED' | 'EMBEDDED'; + diskWarmingMode?: 'FULLY_WARMED' | 'VISIBLE_EARLIER'; + encryptionAtRestProvider?: 'AWS' | 'AZURE' | 'GCP' | 'NONE'; + featureCompatibilityVersion?: string; + featureCompatibilityVersionExpirationDate?: string; // DATE-TIME + globalClusterSelfManagedSharding?: boolean; + mongoDBEmployeeAccessGrant?: { + expirationTime: string; // DATE-TIME + grantType: + | 'CLUSTER_DATABASE_LOGS' + | 'CLUSTER_INFRASTRUCTURE' + | 'CLUSTER_INFRASTRUCTURE_AND_APP_SERVICES_SYNC_DATA'; + links?: Array<{ + href: string; + rel: string; + }>; + }; + paused?: boolean; + pitEnabled?: boolean; + redactClientLogData?: boolean; + replicaSetScalingStrategy?: 'SEQUENTIAL' | 'WORKLOAD_TYPE' | 'NODE_TYPE'; + replicationSpecs?: Array<{ + id?: string; + regionConfigs?: Array<{ + electableSpecs?: { + diskSizeGB?: number; // DOUBLE + diskIOPS?: number; // INTEGER + ebsVolumeType?: 'STANDARD' | 'PROVISIONED'; + instanceSize?: + | 'M10' + | 'M20' + | 'M30' + | 'M40' + | 'M50' + | 'M60' + | 'M80' + | 'M100' + | 'M140' + | 'M200' + | 'M300' + | 'R40' + | 'R50' + | 'R60' + | 'R80' + | 'R200' + | 'R300' + | 'R400' + | 'R700' + | 'M40_NVME' + | 'M50_NVME' + | 'M60_NVME' + | 'M80_NVME' + | 'M200_NVME' + | 'M400_NVME'; + nodeCount?: number; // INTEGER + }; + priority?: number; // INTEGER, Minimum 0, Maximum 7 + providerName?: 'AWS' | 'AZURE' | 'GCP' | 'TENANT'; // DISCRIMINATOR + regionName?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-tenant-object-regionname + analyticsAutoScaling?: { + compute?: { + enabled: boolean; + maxInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-maxinstancesize + minInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-mininstancesize + predictiveEnabled?: boolean; + scaleDownEnabled?: boolean; + }; + diskGB?: { + enabled?: boolean; + }; + }; + analyticsSpecs?: object; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsspecs + autoScaling?: { + compute?: { + enabled: boolean; + maxInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-maxinstancesize + minInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-mininstancesize + predictiveEnabled?: boolean; + scaleDownEnabled?: boolean; + }; + diskGB?: { + enabled?: boolean; + }; + }; + readOnlySpecs?: object; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-readonlyspecs + zoneId?: string; + zoneName?: string; + }>; + rootCertType?: 'ISRGROOTX1'; + stateName?: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'REPAIRING'; + tags?: Array<{ + key: string; + value: string; + }>; + terminationProtectionEnabled?: boolean; + versionReleaseSystem?: 'LTS' | 'CONTINUOUS'; + }>; +}; + +/** + * Atlas database user information + */ +export type AtlasDatabaseUser = { + username: string; // Max length is 1024 + databaseName: 'admin' | '$external'; + groupId: string; + roles: Array<{ + roleName: string; + databaseName: string; + collectionName?: string; + }>; + scopes?: Array<{ + name: string; + type: 'CLUSTER' | 'DATA_LAKE' | 'STREAM'; + }>; + labels?: Array<{ + key: string; + value: string; + }>; + ldapAuthType?: 'NONE' | 'USER' | 'GROUP'; + x509Type?: 'NONE' | 'MANAGED' | 'CUSTOMER'; + awsIAMType?: 'NONE' | 'USER' | 'ROLE'; + links?: Array<{ + href: string; + rel: string; + }>; + deleteAfterDate?: string; // DATE-TIME + description?: string; // Max 100 chars + oidcAuthType?: 'NONE' | 'USER' | 'IDP_GROUP'; +}; + +/** + * Atlas IP access list entry + */ +export type AtlasAccessListEntry = { + groupId: string; + ipAddress?: string; + cidrBlock?: string; + awsSecurityGroup?: string; + comment?: string; + deleteAfterDate?: string; + links?: Array<{ + href: string; + rel: string; + }>; +}; + +/** + * Response wrapper for paginated Atlas API responses + */ +export type AtlasApiResponse = { + results: T[]; + totalCount: number; + links?: Array<{ + href: string; + rel: string; + }>; +}; + +/** + * Parameters for creating IP access list entries + */ +export type CreateAccessListEntryParams = { + ipAddress?: string; + cidrBlock?: string; + awsSecurityGroup?: string; + comment?: string; + deleteAfterDate?: string; +}; diff --git a/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts new file mode 100644 index 000000000..96e5847e5 --- /dev/null +++ b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts @@ -0,0 +1,303 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { + type AtlasAccessListEntry, + type AtlasApiResponse, + type AtlasCluster, + type AtlasDatabaseUser, + type AtlasProject, + type CreateAccessListEntryParams, +} from '../utils/AtlasAdminApiTypes'; +import { AtlasHttpClient } from './AtlasHttpClient'; + +/** + * MongoDB Atlas Administration Client for managing Atlas resources + * Provides methods for projects, clusters, database users, and IP access lists + */ +export class AtlasAdministrationClient { + /** + * Lists all projects (groups) accessible to the authenticated user + * + * @param orgId - The organization id for the Atlas credential instance + * @param options - Optional query parameters + * @returns Promise resolving to list of Atlas projects + */ + public static async listProjects( + orgId: string, + options: { + pageNum?: number; + itemsPerPage?: number; + includeCount?: boolean; + } = {}, + ): Promise> { + const queryParams = new URLSearchParams(); + + if (options.pageNum !== undefined) { + queryParams.append('pageNum', options.pageNum.toString()); + } + if (options.itemsPerPage !== undefined) { + queryParams.append('itemsPerPage', options.itemsPerPage.toString()); + } + if (options.includeCount !== undefined) { + queryParams.append('includeCount', options.includeCount.toString()); + } + + const endpoint = `/groups${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + const response = await AtlasHttpClient.get(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(l10n.t('Failed to list Atlas projects: {0} {1}', response.status.toString(), errorText)); + } + + return response.json() as Promise>; + } + + /** + * Lists all clusters within a specific project + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param options - Optional query parameters + * @returns Promise resolving to list of Atlas clusters + */ + public static async listClusters( + orgId: string, + projectId: string, + options: { + pageNum?: number; + itemsPerPage?: number; + includeCount?: boolean; + } = {}, + ): Promise> { + const queryParams = new URLSearchParams(); + + if (options.pageNum !== undefined) { + queryParams.append('pageNum', options.pageNum.toString()); + } + if (options.itemsPerPage !== undefined) { + queryParams.append('itemsPerPage', options.itemsPerPage.toString()); + } + if (options.includeCount !== undefined) { + queryParams.append('includeCount', options.includeCount.toString()); + } + + const endpoint = `/groups/${projectId}/clusters${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + const response = await AtlasHttpClient.get(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to list clusters for project {0}: {1} {2}', + projectId, + response.status.toString(), + errorText, + ), + ); + } + + return response.json() as Promise>; + } + + /** + * Gets detailed information about a specific cluster, including connection strings + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param clusterName - The name of the cluster + * @returns Promise resolving to cluster details with connection strings + */ + public static async getCluster(orgId: string, projectId: string, clusterName: string): Promise { + const endpoint = `/groups/${projectId}/clusters/${clusterName}`; + const response = await AtlasHttpClient.get(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to get cluster {0} in project {1}: {2} {3}', + clusterName, + projectId, + response.status.toString(), + errorText, + ), + ); + } + + return response.json() as Promise; + } + + /** + * Lists all database users for a specific project + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param options - Optional query parameters + * @returns Promise resolving to list of database users + */ + public static async listDatabaseUsers( + orgId: string, + projectId: string, + options: { + pageNum?: number; + itemsPerPage?: number; + includeCount?: boolean; + } = {}, + ): Promise> { + const queryParams = new URLSearchParams(); + + if (options.pageNum !== undefined) { + queryParams.append('pageNum', options.pageNum.toString()); + } + if (options.itemsPerPage !== undefined) { + queryParams.append('itemsPerPage', options.itemsPerPage.toString()); + } + if (options.includeCount !== undefined) { + queryParams.append('includeCount', options.includeCount.toString()); + } + + const endpoint = `/groups/${projectId}/databaseUsers${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + const response = await AtlasHttpClient.get(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to list database users for project {0}: {1} {2}', + projectId, + response.status.toString(), + errorText, + ), + ); + } + + return response.json() as Promise>; + } + + /** + * Gets the IP access list (firewall entries) for a specific project + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param options - Optional query parameters + * @returns Promise resolving to list of access list entries + */ + public static async getAccessList( + orgId: string, + projectId: string, + options: { + pageNum?: number; + itemsPerPage?: number; + includeCount?: boolean; + } = {}, + ): Promise> { + const queryParams = new URLSearchParams(); + + if (options.pageNum !== undefined) { + queryParams.append('pageNum', options.pageNum.toString()); + } + if (options.itemsPerPage !== undefined) { + queryParams.append('itemsPerPage', options.itemsPerPage.toString()); + } + if (options.includeCount !== undefined) { + queryParams.append('includeCount', options.includeCount.toString()); + } + + const endpoint = `/groups/${projectId}/accessList${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + const response = await AtlasHttpClient.get(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to get access list for project {0}: {1} {2}', + projectId, + response.status.toString(), + errorText, + ), + ); + } + + return response.json() as Promise>; + } + + /** + * Creates one or more IP access list entries for a project + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param entries - Array of access list entries to create + * @returns Promise resolving to created access list entries + */ + public static async createAccessListEntries( + orgId: string, + projectId: string, + entries: CreateAccessListEntryParams[], + ): Promise> { + const endpoint = `/groups/${projectId}/accessList`; + const response = await AtlasHttpClient.post(orgId, endpoint, entries); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to create access list entries for project {0}: {1} {2}', + projectId, + response.status.toString(), + errorText, + ), + ); + } + + return response.json() as Promise>; + } + + /** + * Deletes a specific IP access list entry from a project + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param entryId - The ID of the access list entry to delete (IP address or CIDR block) + * @returns Promise resolving when deletion is complete + */ + public static async deleteAccessListEntry(orgId: string, projectId: string, entryId: string): Promise { + // URL encode the entry ID to handle IP addresses and CIDR blocks properly + const encodedEntryId = encodeURIComponent(entryId); + const endpoint = `/groups/${projectId}/accessList/${encodedEntryId}`; + + try { + const response = await AtlasHttpClient.delete(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to delete access list entry {0} from project {1}: {2} {3}', + entryId, + projectId, + response.status.toString(), + errorText, + ), + ); + } + } catch (error) { + // Re-throw known errors or wrap unknown ones + if (error instanceof Error) { + throw error; + } + throw new Error( + l10n.t( + 'Failed to delete access list entry {0} from project {1}: {2}', + entryId, + projectId, + String(error), + ), + ); + } + } +} diff --git a/src/plugins/service-mongo-atlas/utils/AtlasAuthManager.ts b/src/plugins/service-mongo-atlas/utils/AtlasAuthManager.ts new file mode 100644 index 000000000..942a5f983 --- /dev/null +++ b/src/plugins/service-mongo-atlas/utils/AtlasAuthManager.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { type AtlasCredentials, AtlasCredentialCache } from './AtlasCredentialCache'; + +/** + * Response from Atlas OAuth token request + */ +export interface AtlasOAuthTokenResponse { + access_token: string; + expires_in: number; + token_type: string; +} + +/** + * Atlas authentication manager that handles both OAuth 2.0 and HTTP Digest authentication + * for MongoDB Atlas Service Discovery API requests. + */ +export class AtlasAuthManager { + private static readonly ATLAS_OAUTH_TOKEN_URL = 'https://cloud.mongodb.com/api/oauth/token'; + + /** + * Creates an authorization header for Atlas API requests. + * Handles both OAuth 2.0 Bearer tokens and HTTP Digest authentication. + * + * @param orgId - The organization id for the Atlas credential instance + * @returns Authorization header value or undefined if no valid credentials + */ + public static async getAuthorizationHeader(orgId: string): Promise { + const credentials = AtlasCredentialCache.getAtlasCredentials(orgId); + if (!credentials) { + return undefined; + } + + switch (credentials.authType) { + case 'oauth': + return await this.getOAuthAuthorizationHeader(orgId, credentials); + // Don't need to set the digest header here as it is handled by the HTTP client directly + default: + throw new Error(l10n.t('Unsupported Atlas authentication type: {0}', credentials.authType)); + } + } + + /** + * Gets Basic Authorization header for OAuth client credentials. + * Used for requesting access tokens from the OAuth endpoint. + * + * @param clientId - OAuth client ID + * @param clientSecret - OAuth client secret + * @returns Base64 encoded Basic auth header value + */ + public static getOAuthBasicAuthHeader(clientId: string, clientSecret: string): string { + const credentials = `${clientId}:${clientSecret}`; + const base64Credentials = Buffer.from(credentials, 'utf8').toString('base64'); + return `Basic ${base64Credentials}`; + } + + /** + * Requests a new OAuth access token from MongoDB Atlas. + * + * @param clientId - OAuth client ID + * @param clientSecret - OAuth client secret + * @returns Promise resolving to token response + */ + public static async requestOAuthToken(clientId: string, clientSecret: string): Promise { + const authHeader = this.getOAuthBasicAuthHeader(clientId, clientSecret); + + const response = await fetch(this.ATLAS_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Cache-Control': 'no-cache', + Authorization: authHeader, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=client_credentials', + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(l10n.t('Failed to obtain OAuth token: {0} {1}', response.status.toString(), errorText)); + } + + return response.json() as Promise; + } + + /** + * Clears Atlas authentication state for the given organization. + * + * @param orgId - The organization id for the Atlas credential instance + */ + public static clearAuthentication(orgId: string): void { + AtlasCredentialCache.clearAtlasCredentials(orgId); + } + + /** + * Gets or refreshes OAuth authorization header. + * Automatically handles token expiry and renewal. + */ + private static async getOAuthAuthorizationHeader(orgId: string, credentials: AtlasCredentials): Promise { + if (!credentials.oauth) { + throw new Error(l10n.t('OAuth credentials not found for organization {0}', orgId)); + } + + const { clientId, clientSecret } = credentials.oauth; + + // Check if we have a valid cached token + if (AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)) { + const cachedToken = credentials.oauth.accessToken; + if (cachedToken) { + return `Bearer ${cachedToken}`; + } + } + + // Request new token + const tokenResponse = await this.requestOAuthToken(clientId, clientSecret); + + // Update cache + AtlasCredentialCache.updateAtlasOAuthToken(orgId, tokenResponse.access_token, tokenResponse.expires_in); + + return `Bearer ${tokenResponse.access_token}`; + } +} diff --git a/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts b/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts new file mode 100644 index 000000000..bf06cc6e6 --- /dev/null +++ b/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { CaseInsensitiveMap } from '../../../utils/CaseInsensitiveMap'; + +export interface AtlasCredentials { + /** Authentication type for Atlas */ + authType: 'oauth' | 'digest'; + + /** The unique identifier for the Atlas credential instance === the organization Id */ + orgId: string; + + /** OAuth 2.0 credentials */ + oauth?: { + clientId: string; + clientSecret: string; + // Token cache + accessToken?: string; + tokenExpiry?: number; // Unix timestamp + }; + + /** HTTP Digest credentials */ + digest?: { + publicKey: string; + privateKey: string; + }; +} + +export interface AtlasOrganizationCredentials { + /** The authentication credentials for the organization */ + credentials: AtlasCredentials; +} + +export class AtlasCredentialCache { + // the id of the organization === the orgId -> the atlas credentials + private static _store: CaseInsensitiveMap = new CaseInsensitiveMap(); + + /** + * Sets MongoDB Atlas OAuth 2.0 credentials for service discovery. + * + * @param orgId - The organization id for the Atlas credential instance + * @param clientId - OAuth client ID + * @param clientSecret - OAuth client secret + */ + public static setAtlasOAuthCredentials(orgId: string, clientId: string, clientSecret: string): void { + const credentials: AtlasCredentials = { + orgId, + authType: 'oauth', + oauth: { + clientId, + clientSecret, + }, + }; + + AtlasCredentialCache._store.set(orgId, credentials); + } + + /** + * Sets MongoDB Atlas HTTP Digest credentials for service discovery. + * + * @param orgId - The organization id for the Atlas credential instance + * @param publicKey - Atlas API public key + * @param privateKey - Atlas API private key + */ + public static setAtlasDigestCredentials(orgId: string, publicKey: string, privateKey: string): void { + const credentials: AtlasCredentials = { + orgId, + authType: 'digest', + digest: { + publicKey, + privateKey, + }, + }; + + AtlasCredentialCache._store.set(orgId, credentials); + } + + /** + * Updates the OAuth access token cache for Atlas credentials. + * + * @param orgId - The organization id for the Atlas credential instance + * @param accessToken - The access token received from OAuth + * @param expiresInSeconds - Token lifetime in seconds, maximum value is 3600 + */ + public static updateAtlasOAuthToken(orgId: string, accessToken: string, expiresInSeconds: number = 3600): void { + const credentials = AtlasCredentialCache._store.get(orgId); + if (!credentials?.oauth) { + throw new Error(l10n.t('No Atlas OAuth credentials found for organization {0}', orgId)); + } + + // expiresInSeconds should never exceeds 3600 as the lifecycle of token for Atlas administration API is + // 1 hour(3600 seconds): https://www.mongodb.com/docs/atlas/configure-api-access/#request-an-access-token. + const tokenExpiry = Date.now() + (expiresInSeconds > 3600 ? 3600 : expiresInSeconds) * 1000; + credentials.oauth.accessToken = accessToken; + credentials.oauth.tokenExpiry = tokenExpiry; + + AtlasCredentialCache._store.set(orgId, credentials); + } + + /** + * Gets Atlas credentials for a given cluster ID. + * + * @param orgId - The organization id for the Atlas credential instance + * @returns Atlas credentials or undefined if not found + */ + public static getAtlasCredentials(orgId: string): AtlasCredentials | undefined { + return AtlasCredentialCache._store.get(orgId); + } + + /** + * Checks if the OAuth token is still valid (not expired). + * + * @param orgId - The organization id for the Atlas credential instance + * @returns True if token exists and is valid, false otherwise + */ + public static isAtlasOAuthTokenValid(orgId: string): boolean { + const credentials = AtlasCredentialCache._store.get(orgId); + const oauth = credentials?.oauth; + + if (!oauth?.accessToken || !oauth.tokenExpiry) { + return false; + } + + // Add 60 second buffer to avoid edge cases + return Date.now() < oauth.tokenExpiry - 60000; + } + + /** + * Clears Atlas authentication state and removes credentials. + * + * @param orgId - The organization id for the Atlas credential instance + */ + public static clearAtlasCredentials(orgId: string): void { + const credentials = AtlasCredentialCache._store.get(orgId); + if (credentials) { + AtlasCredentialCache._store.delete(orgId); + } + } +} diff --git a/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts b/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts new file mode 100644 index 000000000..c5b266dc2 --- /dev/null +++ b/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import DigestClient from 'digest-fetch'; +import { AtlasAuthManager } from './AtlasAuthManager'; +import { type AtlasCredentials, AtlasCredentialCache } from './AtlasCredentialCache'; + +// Type definitions for digest-fetch client +interface DigestFetchClient { + fetch(url: string, options?: RequestInit): Promise; +} + +/** + * HTTP client for MongoDB Atlas API that handles authentication + */ +export class AtlasHttpClient { + private static readonly ATLAS_API_BASE_URL = 'https://cloud.mongodb.com/api/atlas/v2'; + private static readonly ATLAS_DIGEST_API_VERSION = 'application/vnd.atlas.2025-03-12+json'; + + /** + * Makes an authenticated GET request to the Atlas API + * + * @param orgId - The organization id for the Atlas credential instance + * @param endpoint - API endpoint path (e.g., '/groups') + * @returns Response from Atlas API + */ + public static async get(orgId: string, endpoint: string): Promise { + return this.request(orgId, 'GET', endpoint); + } + + /** + * Makes an authenticated POST request to the Atlas API + * + * @param orgId - The organization id for the Atlas credential instance + * @param endpoint - API endpoint path (e.g., '/groups') + * @param body - Request body data + * @returns Response from Atlas API + */ + public static async post(orgId: string, endpoint: string, body?: unknown): Promise { + return this.request(orgId, 'POST', endpoint, body); + } + + /** + * Makes an authenticated DELETE request to the Atlas API + * + * @param orgId - The organization id for the Atlas credential instance + * @param endpoint - API endpoint path (e.g., '/groups/{id}') + * @returns Response from Atlas API + */ + public static async delete(orgId: string, endpoint: string): Promise { + return this.request(orgId, 'DELETE', endpoint); + } + + /** + * Makes an authenticated request to the Atlas API with proper authentication handling + */ + private static async request(orgId: string, method: string, endpoint: string, body?: unknown): Promise { + const credentials = AtlasCredentialCache.getAtlasCredentials(orgId); + if (!credentials) { + throw new Error(l10n.t('No Atlas credentials found for organization {0}', orgId)); + } + + const url = `${this.ATLAS_API_BASE_URL}${endpoint}`; + + switch (credentials.authType) { + case 'oauth': + return this.makeOAuthRequest(orgId, method, url, body); + case 'digest': + return this.makeDigestRequest(credentials, method, url, body); + default: + throw new Error(l10n.t('Unsupported Atlas authentication type: {0}', credentials.authType)); + } + } + + /** + * Makes OAuth authenticated request + */ + private static async makeOAuthRequest( + orgId: string, + method: string, + url: string, + body?: unknown, + ): Promise { + const authHeader = await AtlasAuthManager.getAuthorizationHeader(orgId); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new Error(l10n.t('Failed to obtain valid OAuth token')); + } + + const headers: Record = { + Authorization: authHeader, + 'Content-Type': 'application/json', + Accept: this.ATLAS_DIGEST_API_VERSION, + }; + + const requestInit: RequestInit = { + method, + headers, + }; + + if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { + requestInit.body = JSON.stringify(body); + } + + return fetch(url, requestInit); + } + + /** + * Makes HTTP Digest authenticated request. + * This implementation uses digest-fetch library for HTTP Digest authentication. + */ + private static async makeDigestRequest( + credentials: AtlasCredentials, + method: string, + url: string, + body?: unknown, + ): Promise { + if (!credentials.digest) { + throw new Error(l10n.t('Digest credentials not found')); + } + + const { publicKey, privateKey } = credentials.digest; + + const client = new DigestClient(publicKey, privateKey) as DigestFetchClient; + + const requestInit: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + Accept: this.ATLAS_DIGEST_API_VERSION, + }, + }; + + if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { + requestInit.body = JSON.stringify(body); + } + + // Make digest authenticated request + const response = await client.fetch(url, requestInit); + + if (!response.ok) { + const errorText: string = await response.text(); + throw new Error( + l10n.t('Request failed with status {status}: {errorText}', { status: response.status, errorText }), + ); + } + + return response; + } +}