From a5c2142adf85ba91b9abd2bb340ff2fbef3a7964 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Fri, 6 Dec 2024 16:23:59 +0800 Subject: [PATCH 1/7] 1.add new service --- .../cloud-service-integraion/azure-service.ts | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts diff --git a/packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts b/packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts new file mode 100644 index 00000000000..f7822c8243d --- /dev/null +++ b/packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts @@ -0,0 +1,177 @@ +import { type AuthenticationResult, CryptoProvider, PublicClientApplication } from '@azure/msal-node'; +import crypto from 'crypto'; +import { net, shell } from 'electron'; + +import { INSOMNIA_AZURE_CLIENT_ID, INSOMNIA_AZURE_REDIRECT_URI, INSOMNIA_FETCH_TIME_OUT } from '../../../common/constants'; +import { insomniaFetch } from '../../../ui/insomniaFetch'; +import type { CloudServiceResult, ICloudService } from './types'; + +export type AzureVaultType = 'key' | 'secret'; +export interface AzureGetSecretConfig { + type: AzureVaultType; +} +interface AzureSecretAttributes { + enabled: boolean; + created: number; + updated: number; + exportable: boolean; +} +export interface AzureKeyObjectResponse extends AzureSecretAttributes { + key: JsonWebKey; +} +export interface AzureSecretObjectResponse extends AzureSecretAttributes { + value: string; + contentType: string; + id: string; +} +export type AzureSecretResponse = AzureKeyObjectResponse | AzureSecretObjectResponse; + +// singeleton azure client instance +let azureClient: PublicClientApplication; +let redirect_uri: string; +let verifier: string; +let challenge: string; +export const scopes = ['https://vault.azure.net/user_impersonation']; +export const azureEndpointHost = 'https://login.microsoftonline.com'; +export const authority = `${azureEndpointHost}/common`; + +const getAzureConfig = async () => { + // Validate and use the environment variables if provided + if ( + (INSOMNIA_AZURE_REDIRECT_URI && !INSOMNIA_AZURE_CLIENT_ID) || + (!INSOMNIA_AZURE_REDIRECT_URI && INSOMNIA_AZURE_CLIENT_ID) + ) { + throw new Error('Azure Client ID and Redirect URI must both be set.'); + } + + if (INSOMNIA_AZURE_CLIENT_ID && INSOMNIA_AZURE_REDIRECT_URI) { + return { + clientId: INSOMNIA_AZURE_CLIENT_ID, + redirectUri: INSOMNIA_AZURE_REDIRECT_URI, + }; + } + + // TODO Get Azure config from server + return insomniaFetch<{ applicationId: string; redirectUri: string; error?: string }>({ + path: '/v1/oauth/azure/config', + method: 'GET', + sessionId: '', + }).then(data => { + return { + clientId: data.applicationId, + redirectUri: data.redirectUri, + }; + }); +}; + +const getAzureClient = async () => { + if (!azureClient) { + const azureConfig = await getAzureConfig(); + const { clientId, redirectUri } = azureConfig; + azureClient = new PublicClientApplication({ + auth: { + clientId, + authority, + }, + }); + redirect_uri = redirectUri; + } + return azureClient; +}; + +const generatePkceCodes = async () => { + const crypoProvider = new CryptoProvider(); + ({ verifier, challenge } = await crypoProvider.generatePkceCodes()); +}; +// generate Pkce Code on initialize +generatePkceCodes(); + +export class AzureService implements ICloudService { + _credential: AuthenticationResult; + + constructor(credential: AuthenticationResult) { + this._credential = credential; + } + + static async openAuthUrl() { + const azureClient = await getAzureClient(); + const authUrl = await azureClient.getAuthCodeUrl({ + redirectUri: redirect_uri, + scopes, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + }); + // eslint-disable-next-line no-restricted-properties + shell.openExternal(authUrl); + } + + async authorize(code: string): Promise> { + const azureClient = await getAzureClient(); + try { + const authResult = await azureClient.acquireTokenByCode({ + scopes, + redirectUri: redirect_uri, + code, + codeVerifier: verifier, + }); + // generate new Pkce codes after a sucess auth + await generatePkceCodes(); + return { + success: true, + result: authResult, + }; + } catch (error) { + return { + success: false, + result: null, + error: error.toString(), + }; + } + }; + + async getSecret(identifier: string): Promise> { + const { accessToken } = this._credential; + const apiVersion = '7.4'; + // Using Azure rest api to get key/secret. Refer: + // https://learn.microsoft.com/en-us/rest/api/keyvault/keys/get-key/get-key?view=rest-keyvault-keys-7.4&tabs=HTTP + // https://learn.microsoft.com/en-us/rest/api/keyvault/secrets/get-secret/get-secret?view=rest-keyvault-secrets-7.4&tabs=HTTP + try { + const params = new URLSearchParams({ + 'api-version': apiVersion, + }); + const secretUrl = `${identifier}?${params.toString()}`; + const requestConfig: RequestInit = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + signal: AbortSignal.timeout(INSOMNIA_FETCH_TIME_OUT), + }; + const secretResponse = await net.fetch(secretUrl, requestConfig); + if (secretResponse.ok) { + const secretBody = await secretResponse.json() as AzureSecretResponse; + return { + success: true, + result: secretBody, + }; + } else { + return { + success: false, + result: null, + }; + } + } catch (error) { + return { + success: false, + result: null, + error: error.toString(), + }; + } + } + + getUniqueCacheKey(identifier: string) { + const uniqueKey = identifier; + const uniqueKeyHash = crypto.createHash('md5').update(uniqueKey).digest('hex'); + return uniqueKeyHash; + } +} From 530b5fb653597d0c7474da409894b266246eeb62 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Fri, 6 Dec 2024 16:24:27 +0800 Subject: [PATCH 2/7] 1.do some modification --- .../cloud-service-integraion/azure-service.ts | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts b/packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts index f7822c8243d..9c4728d1e98 100644 --- a/packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts +++ b/packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts @@ -4,7 +4,7 @@ import { net, shell } from 'electron'; import { INSOMNIA_AZURE_CLIENT_ID, INSOMNIA_AZURE_REDIRECT_URI, INSOMNIA_FETCH_TIME_OUT } from '../../../common/constants'; import { insomniaFetch } from '../../../ui/insomniaFetch'; -import type { CloudServiceResult, ICloudService } from './types'; +import { type CloudServiceResult, type ICloudService, OAuthCloudService } from './types'; export type AzureVaultType = 'key' | 'secret'; export interface AzureGetSecretConfig { @@ -86,10 +86,11 @@ const generatePkceCodes = async () => { // generate Pkce Code on initialize generatePkceCodes(); -export class AzureService implements ICloudService { +export class AzureService extends OAuthCloudService implements ICloudService { _credential: AuthenticationResult; constructor(credential: AuthenticationResult) { + super(); this._credential = credential; } @@ -105,7 +106,31 @@ export class AzureService implements ICloudService { shell.openExternal(authUrl); } - async authorize(code: string): Promise> { + static async exchangeCode(code: string): Promise> { + const azureClient = await getAzureClient(); + try { + const authResult = await azureClient.acquireTokenByCode({ + scopes, + redirectUri: redirect_uri, + code, + codeVerifier: verifier, + }); + // generate new Pkce codes after a sucess auth + await generatePkceCodes(); + return { + success: true, + result: authResult, + }; + } catch (error) { + return { + success: false, + result: null, + error: error.toString(), + }; + } + }; + + async authenticate(code: string): Promise> { const azureClient = await getAzureClient(); try { const authResult = await azureClient.acquireTokenByCode({ From 0ff93c0e85e0162bea29f8d23217c8c20b41bfa2 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Mon, 9 Dec 2024 14:00:10 +0800 Subject: [PATCH 3/7] 1.save work for azure vault integration --- package-lock.json | 45 +++++++++--- packages/insomnia/package.json | 1 + packages/insomnia/src/common/constants.ts | 2 + .../cloud-service-integraion/aws-service.ts | 2 +- .../cloud-service-integraion/azure-service.ts | 61 ++++++---------- .../cloud-service-integraion/cloud-service.ts | 32 +++++++- .../ipc/cloud-service-integraion/types.ts | 20 ++++- packages/insomnia/src/main/ipc/electron.ts | 6 +- .../insomnia/src/models/cloud-credential.ts | 8 +- packages/insomnia/src/preload.ts | 2 + .../aws-credential-form.tsx | 4 +- .../cloud-credential-modal.tsx | 70 +++++++++++++++++- .../ui/components/modals/settings-modal.tsx | 1 + .../settings/cloud-service-credentials.tsx | 32 ++++---- .../components/templating/external-vault.ts | 73 +++++++++++++++++++ .../external-vault/azure-key-vault-form.tsx | 64 ++++++++++++++++ .../external-vault/external-vault-form.tsx | 28 +++++-- .../templating/local-template-tags.ts | 43 +---------- packages/insomnia/src/ui/routes/actions.tsx | 31 +++++--- packages/insomnia/src/ui/routes/root.tsx | 33 +++++++++ 20 files changed, 424 insertions(+), 134 deletions(-) create mode 100644 packages/insomnia/src/ui/components/templating/external-vault.ts create mode 100644 packages/insomnia/src/ui/components/templating/external-vault/azure-key-vault-form.tsx diff --git a/package-lock.json b/package-lock.json index 92593fd66d7..e278c677150 100644 --- a/package-lock.json +++ b/package-lock.json @@ -976,6 +976,38 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/@azure/msal-common": { + "version": "14.16.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.0.tgz", + "integrity": "sha512-1KOZj9IpcDSwpNiQNjt0jDYZpQvNZay7QAEi/5DLubay40iGYtLzya/jbjRPLyOTZhEKyL1MzPuw2HqBCjceYA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.2.tgz", + "integrity": "sha512-An7l1hEr0w1HMMh1LU+rtDtqL7/jw74ORlc9Wnh06v7TU/xpG39/Zdr1ZJu3QpjUfKJ+E0/OXMW8DRSWTlh7qQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -10250,7 +10282,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/buffer-fill": { @@ -12249,7 +12280,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -16704,7 +16734,6 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "dev": true, "license": "MIT", "dependencies": { "jws": "^3.2.2", @@ -16796,7 +16825,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "dev": true, "license": "MIT", "dependencies": { "buffer-equal-constant-time": "1.0.1", @@ -16808,7 +16836,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "dev": true, "license": "MIT", "dependencies": { "jwa": "^1.4.1", @@ -17259,14 +17286,12 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isequal": { @@ -17279,28 +17304,24 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true, "license": "MIT" }, "node_modules/lodash.kebabcase": { @@ -17321,7 +17342,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.snakecase": { @@ -24534,6 +24554,7 @@ "@apidevtools/swagger-parser": "10.1.0", "@aws-sdk/client-secrets-manager": "^3.686.0", "@aws-sdk/client-sts": "^3.686.0", + "@azure/msal-node": "^2.16.2", "@bufbuild/protobuf": "^1.8.0", "@connectrpc/connect": "^1.4.0", "@connectrpc/connect-node": "^1.4.0", diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 091788f37a9..fbb1f9520f1 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -44,6 +44,7 @@ "@getinsomnia/node-libcurl": "^2.31.4", "@grpc/grpc-js": "^1.12.0", "@grpc/proto-loader": "^0.7.13", + "@azure/msal-node": "^2.16.2", "@seald-io/nedb": "^4.0.4", "@segment/analytics-node": "2.1.0", "apiconnect-wsdl": "2.0.36", diff --git a/packages/insomnia/src/common/constants.ts b/packages/insomnia/src/common/constants.ts index 4fe6e8d523c..7560faafa4c 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -11,6 +11,8 @@ const env = process[ENV]; export const INSOMNIA_GITLAB_REDIRECT_URI = env.INSOMNIA_GITLAB_REDIRECT_URI; export const INSOMNIA_GITLAB_CLIENT_ID = env.INSOMNIA_GITLAB_CLIENT_ID; export const INSOMNIA_GITLAB_API_URL = env.INSOMNIA_GITLAB_API_URL; +export const INSOMNIA_AZURE_CLIENT_ID = env.INSOMNIA_AZURE_CLIENT_ID; +export const INSOMNIA_AZURE_REDIRECT_URI = env.INSOMNIA_AZURE_REDIRECT_URI; export const PLAYWRIGHT = env.PLAYWRIGHT; // App Stuff export const getSkipOnboarding = () => env.INSOMNIA_SKIP_ONBOARDING; diff --git a/packages/insomnia/src/main/ipc/cloud-service-integraion/aws-service.ts b/packages/insomnia/src/main/ipc/cloud-service-integraion/aws-service.ts index 431a7e26b90..6ee5874456b 100644 --- a/packages/insomnia/src/main/ipc/cloud-service-integraion/aws-service.ts +++ b/packages/insomnia/src/main/ipc/cloud-service-integraion/aws-service.ts @@ -14,7 +14,7 @@ export class AWSService implements ICloudService { this._credential = credential; } - async authorize(): Promise> { + async authenticate(): Promise> { const { region, accessKeyId, secretAccessKey, sessionToken } = this._credential; const stsClient = new STSClient({ region, diff --git a/packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts b/packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts index 9c4728d1e98..97d32f8d650 100644 --- a/packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts +++ b/packages/insomnia/src/main/ipc/cloud-service-integraion/azure-service.ts @@ -1,4 +1,4 @@ -import { type AuthenticationResult, CryptoProvider, PublicClientApplication } from '@azure/msal-node'; +import { type AuthenticationResult, AuthError, CryptoProvider, PublicClientApplication } from '@azure/msal-node'; import crypto from 'crypto'; import { net, shell } from 'electron'; @@ -106,7 +106,7 @@ export class AzureService extends OAuthCloudService implements ICloudService { shell.openExternal(authUrl); } - static async exchangeCode(code: string): Promise> { + static async exchangeCode({ code }: { code: string }): Promise> { const azureClient = await getAzureClient(); try { const authResult = await azureClient.acquireTokenByCode({ @@ -115,50 +115,43 @@ export class AzureService extends OAuthCloudService implements ICloudService { code, codeVerifier: verifier, }); - // generate new Pkce codes after a sucess auth + // generate new Pkce codes after a success auth await generatePkceCodes(); return { success: true, result: authResult, }; } catch (error) { + if (error instanceof AuthError) { + const { errorMessage, errorCode } = error; + return { + success: false, + result: null, + error: { errorMessage, errorCode }, + }; + } return { success: false, result: null, - error: error.toString(), + error: { errorMessage: error.toString(), errorCode: '' }, }; } - }; + } - async authenticate(code: string): Promise> { - const azureClient = await getAzureClient(); - try { - const authResult = await azureClient.acquireTokenByCode({ - scopes, - redirectUri: redirect_uri, - code, - codeVerifier: verifier, - }); - // generate new Pkce codes after a sucess auth - await generatePkceCodes(); - return { - success: true, - result: authResult, - }; - } catch (error) { - return { - success: false, - result: null, - error: error.toString(), - }; - } - }; + async authenticate() { + // Azure uses OAuth authenticaion flow. + } + + getUniqueCacheKey(identifier: string) { + const uniqueKey = identifier; + const uniqueKeyHash = crypto.createHash('md5').update(uniqueKey).digest('hex'); + return uniqueKeyHash; + } async getSecret(identifier: string): Promise> { const { accessToken } = this._credential; const apiVersion = '7.4'; - // Using Azure rest api to get key/secret. Refer: - // https://learn.microsoft.com/en-us/rest/api/keyvault/keys/get-key/get-key?view=rest-keyvault-keys-7.4&tabs=HTTP + // Using Azure rest api to get secret. Refer: // https://learn.microsoft.com/en-us/rest/api/keyvault/secrets/get-secret/get-secret?view=rest-keyvault-secrets-7.4&tabs=HTTP try { const params = new URLSearchParams({ @@ -189,14 +182,8 @@ export class AzureService extends OAuthCloudService implements ICloudService { return { success: false, result: null, - error: error.toString(), + error: { errorMessage: error.toString(), errorCode: '' }, }; } } - - getUniqueCacheKey(identifier: string) { - const uniqueKey = identifier; - const uniqueKeyHash = crypto.createHash('md5').update(uniqueKey).digest('hex'); - return uniqueKeyHash; - } } diff --git a/packages/insomnia/src/main/ipc/cloud-service-integraion/cloud-service.ts b/packages/insomnia/src/main/ipc/cloud-service-integraion/cloud-service.ts index 6feb79e6aac..e43272d2e81 100644 --- a/packages/insomnia/src/main/ipc/cloud-service-integraion/cloud-service.ts +++ b/packages/insomnia/src/main/ipc/cloud-service-integraion/cloud-service.ts @@ -1,7 +1,10 @@ +import type { AuthenticationResult as AzureOAuthCredential } from '@azure/msal-node'; + import * as models from '../../../models'; import type { AWSTemporaryCredential, CloudeProviderCredentialType, CloudProviderName } from '../../../models/cloud-credential'; import { ipcMainHandle, ipcMainOn } from '../electron'; import { type AWSGetSecretConfig, AWSService } from './aws-service'; +import { AzureService } from './azure-service'; import { type MaxAgeUnit, VaultCache } from './vault-cache'; // in-memory cache for fetched vault secrets @@ -12,6 +15,8 @@ export interface cloudServiceBridgeAPI { getSecret: typeof getSecret; clearCache: typeof clearVaultCache; setCacheMaxAge: typeof setCacheMaxAge; + openAuthUrl: typeof openAuthUrl; + exchangeCode: typeof exchangeCode; } export interface CloudServiceAuthOption { provider: CloudProviderName; @@ -26,17 +31,20 @@ export type CloudServiceGetSecretConfig = AWSGetSecretConfig; export function registerCloudServiceHandlers() { ipcMainHandle('cloudService.authenticate', (_event, options) => cspAuthentication(options)); ipcMainHandle('cloudService.getSecret', (_event, options) => getSecret(options)); + ipcMainHandle('cloudService.exchangeCode', (_event, type, data) => exchangeCode(type, data)); + ipcMainOn('cloudService.openAuthUrl', (_event, type) => openAuthUrl(type)); ipcMainOn('cloudService.clearCache', () => clearVaultCache()); ipcMainOn('cloudService.setCacheMaxAge', (_event, { maxAge, unit }) => setCacheMaxAge(maxAge, unit)); } -type CredentialType = AWSTemporaryCredential; // factory pattern to create cloud service class based on its provider name class ServiceFactory { - static createCloudService(name: CloudProviderName, credential: CredentialType) { + static createCloudService(name: CloudProviderName, credential: CloudeProviderCredentialType) { switch (name) { case 'aws': return new AWSService(credential as AWSTemporaryCredential); + case 'azure': + return new AzureService(credential as AzureOAuthCredential); default: throw new Error('Invalid cloud service provider name'); } @@ -51,11 +59,29 @@ const setCacheMaxAge = (newAge: number, unit: MaxAgeUnit = 'min') => { return vaultCache.setMaxAge(newAge, unit); }; +const openAuthUrl = (type: 'azure') => { + switch (type) { + case 'azure': + AzureService.openAuthUrl(); + break; + default: + return; + } +}; + +const exchangeCode = async (type: 'azure', data: any) => { + // eslint-disable-next-line default-case + switch (type) { + case 'azure': + return AzureService.exchangeCode(data); + } +}; + // authenticate with cloud service provider const cspAuthentication = (options: CloudServiceAuthOption) => { const { provider, credentials } = options; const cloudService = ServiceFactory.createCloudService(provider, credentials); - return cloudService.authorize(); + return cloudService.authenticate(); }; const getSecret = async (options: CloudServiceSecretOption) => { diff --git a/packages/insomnia/src/main/ipc/cloud-service-integraion/types.ts b/packages/insomnia/src/main/ipc/cloud-service-integraion/types.ts index ae2b6164b6e..a19cb843a9a 100644 --- a/packages/insomnia/src/main/ipc/cloud-service-integraion/types.ts +++ b/packages/insomnia/src/main/ipc/cloud-service-integraion/types.ts @@ -7,9 +7,8 @@ export interface CloudServiceResult { result: T | null; error?: CloudServiceError; } - export interface ICloudService { - authorize(): Promise; + authenticate(...args: any[]): Promise; getSecret(secretName: string, config?: T): Promise; getSecret(secretName: string): Promise; getUniqueCacheKey(secretName: string, config?: T): string; @@ -23,3 +22,20 @@ export interface AWSSecretConfig { SecretType: AWSSecretType; SecretKey?: string; }; + +export type AzureSecretType = 'secret' | 'key'; +export interface AzureSecretConfig { + secretIdentifier: string; + secretType: AzureSecretType; +} +export type ExternalVaultConfig = AWSSecretConfig | AzureSecretConfig; + +export abstract class OAuthCloudService { + static async openAuthUrl() { + throw new Error('Subclasses must implement the static method openAuthUrl'); + }; + + static async exchangeCode(data: any): Promise { + throw new Error(`Subclasses must implement the static method exchangeCode with ${data}`); + }; +}; diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 9162aa4c15c..f4dced77da5 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -30,7 +30,8 @@ export type HandleChannels = | 'writeFile' | 'extractJsonFileFromPostmanDataDumpArchive' | 'cloudService.authenticate' - | 'cloudService.getSecret'; + | 'cloudService.getSecret' + | 'cloudService.exchangeCode'; export const ipcMainHandle = ( channel: HandleChannels, @@ -73,7 +74,8 @@ export type MainOnChannels = | 'updateLatestStepName' | 'startExecution' | 'cloudService.setCacheMaxAge' - | 'cloudService.clearCache'; + | 'cloudService.clearCache' + | 'cloudService.openAuthUrl'; export type RendererOnChannels = 'clear-all-models' | 'clear-model' diff --git a/packages/insomnia/src/models/cloud-credential.ts b/packages/insomnia/src/models/cloud-credential.ts index cba9d1dd8b3..6c1fb6e1fd0 100644 --- a/packages/insomnia/src/models/cloud-credential.ts +++ b/packages/insomnia/src/models/cloud-credential.ts @@ -1,3 +1,5 @@ +import type { AuthenticationResult as AzureOAuthCredential } from '@azure/msal-node'; + import { database as db } from '../common/database'; import type { BaseModel } from './index'; @@ -12,7 +14,7 @@ export interface AWSTemporaryCredential { sessionToken: string; region: string; } -export type CloudeProviderCredentialType = AWSTemporaryCredential; +export type CloudeProviderCredentialType = AWSTemporaryCredential | AzureOAuthCredential; export interface BaseCloudCredential { name: string; @@ -72,6 +74,10 @@ export function remove(credential: CloudProviderCredential) { return db.remove(credential); } +export function getByName(name: string, provider: CloudProviderName) { + return db.find(type, { name, provider }); +} + export function all() { return db.all(type); } diff --git a/packages/insomnia/src/preload.ts b/packages/insomnia/src/preload.ts index 25816a87e15..2019e48b71f 100644 --- a/packages/insomnia/src/preload.ts +++ b/packages/insomnia/src/preload.ts @@ -45,8 +45,10 @@ const grpc: gRPCBridgeAPI = { const cloudService: cloudServiceBridgeAPI = { authenticate: options => ipcRenderer.invoke('cloudService.authenticate', options), getSecret: options => ipcRenderer.invoke('cloudService.getSecret', options), + exchangeCode: (type, data) => ipcRenderer.invoke('cloudService.exchangeCode', type, data), setCacheMaxAge: options => ipcRenderer.send('cloudService.setCacheMaxAge', options), clearCache: () => ipcRenderer.send('cloudService.clearCache'), + openAuthUrl: type => ipcRenderer.send('cloudService.openAuthUrl', type), }; const main: Window['main'] = { diff --git a/packages/insomnia/src/ui/components/modals/cloud-credential-modal/aws-credential-form.tsx b/packages/insomnia/src/ui/components/modals/cloud-credential-modal/aws-credential-form.tsx index 4de752780ca..a10c3e195ba 100644 --- a/packages/insomnia/src/ui/components/modals/cloud-credential-modal/aws-credential-form.tsx +++ b/packages/insomnia/src/ui/components/modals/cloud-credential-modal/aws-credential-form.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Button, Input, Label, TextField } from 'react-aria-components'; -import { AWSCredentialType, type BaseCloudCredential, type CloudProviderCredential, type CloudProviderName } from '../../../../models/cloud-credential'; +import { AWSCredentialType, type AWSTemporaryCredential, type BaseCloudCredential, type CloudProviderCredential, type CloudProviderName } from '../../../../models/cloud-credential'; import { Icon } from '../../icon'; export interface AWSCredentialFormProps { @@ -37,7 +37,7 @@ export const AWSCredentialForm = (props: AWSCredentialFormProps) => { const { data, onSubmit, isLoading, errorMessage } = props; const isEdit = !!data; const { name, credentials } = data || initialFormValue; - const { accessKeyId, secretAccessKey, sessionToken, region } = credentials!; + const { accessKeyId, secretAccessKey, sessionToken, region } = credentials! as AWSTemporaryCredential; const [hideValueItemNames, setHideValueItemNames] = useState(['accessKeyId', 'secretAccessKey', 'sessionToken']); const showOrHideItemValue = (name: string) => { diff --git a/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx b/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx index 5a3fb71f8ea..44a339c5a6f 100644 --- a/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components'; import { useFetcher } from 'react-router-dom'; @@ -14,6 +14,9 @@ export interface CloudCredentialModalProps { export const CloudCredentialModal = (props: CloudCredentialModalProps) => { const { provider, providerCredential, onClose } = props; + const [isAuthenticating, setIsAuthenticating] = useState(false); + const [error, setError] = useState(''); + const [manulInputUrl, setManualInputUrl] = useState(''); const providerDisplayName = getProviderDisplayName(provider); const cloudCredentialFetcher = useFetcher(); const isEditing = !!providerCredential; @@ -26,11 +29,11 @@ export const CloudCredentialModal = (props: CloudCredentialModalProps) => { return undefined; }, [cloudCredentialFetcher.data, cloudCredentialFetcher.state, provider]); - const handleFormSubmit = (data: BaseCloudCredential) => { - const { name, credentials } = data; + const handleFormSubmit = (data: BaseCloudCredential & { isAuthenticated?: boolean }) => { + const { name, credentials, isAuthenticated = false } = data; const formAction = isEditing ? `/cloud-credential/${providerCredential._id}/update` : '/cloud-credential/new'; cloudCredentialFetcher.submit( - JSON.stringify({ name, credentials, provider }), + JSON.stringify({ name, credentials, provider, isAuthenticated }), { action: formAction, method: 'post', @@ -39,6 +42,31 @@ export const CloudCredentialModal = (props: CloudCredentialModalProps) => { ); }; + const exchangeAzureCode = async () => { + try { + setError(''); + const parsedURL = new URL(manulInputUrl); + const code = parsedURL.searchParams.get('code'); + if (typeof code === 'string') { + const authResult = await window.main.cloudService.exchangeCode('azure', { code }); + const { success, result, error } = authResult; + if (success) { + const { account, uniqueId } = result!; + handleFormSubmit({ + name: account?.username || uniqueId, + provider: 'azure', + credentials: result!, + isAuthenticated: true, + }); + } else { + setError(error!.errorMessage); + } + } + } catch (error) { + setError(error.toString()); + } + }; + useEffect(() => { // close modal if submit success if (cloudCredentialFetcher.data && !cloudCredentialFetcher.data.error && cloudCredentialFetcher.state === 'idle') { @@ -83,6 +111,40 @@ export const CloudCredentialModal = (props: CloudCredentialModalProps) => { errorMessage={fetchErrorMessage} /> } + {provider === 'azure' && +
+ { + setIsAuthenticating(true); + window.main.cloudService.openAuthUrl('azure'); + }} + > + {isAuthenticating ? 'Authenticating' : 'Click to authenticate'} with Azure + + {isAuthenticating && + + } + {error && ( +

+ {error} +

+ )} +
+ } )} diff --git a/packages/insomnia/src/ui/components/modals/settings-modal.tsx b/packages/insomnia/src/ui/components/modals/settings-modal.tsx index a4d7728d8f1..2d232d784a7 100644 --- a/packages/insomnia/src/ui/components/modals/settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/settings-modal.tsx @@ -28,6 +28,7 @@ export const TAB_INDEX_SHORTCUTS = 'keyboard'; export const TAB_INDEX_THEMES = 'themes'; export const TAB_INDEX_PLUGINS = 'plugins'; export const TAB_INDEX_AI = 'ai'; +export const TAB_CLOUD_CREDENTIAL = 'cloudCred'; export const SettingsModal = forwardRef((props, ref) => { const [defaultTabKey, setDefaultTabKey] = useState('general'); diff --git a/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx b/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx index e08b137bd26..4068a41ebfd 100644 --- a/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx +++ b/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx @@ -21,11 +21,11 @@ const createCredentialItemList: createCredentialItemType[] = [ id: 'aws', name: getProviderDisplayName('aws'), }, - // TODO only support aws for now - // { - // id: 'azure', - // name: getProviderDisplayName('azure'), - // }, + { + id: 'azure', + name: getProviderDisplayName('azure'), + }, + // TODO support gcp // { // id: 'gcp', // name: getProviderDisplayName('gcp'), @@ -65,6 +65,10 @@ export const CloudServiceCredentialList = () => { }); }; + const handleCreateCloudServiceCredential = (key: CloudProviderName) => { + setModalState({ show: true, provider: key as CloudProviderName }); + }; + if (!isEnterprisePlan) { return ( { placement='bottom right' > setModalState({ show: true, provider: key as CloudProviderName })} + onAction={key => handleCreateCloudServiceCredential(key as CloudProviderName)} items={createCredentialItemList} className="border select-none text-sm min-w-max border-solid border-[--hl-sm] shadow-lg bg-[--color-bg] py-2 rounded-md overflow-y-auto max-h-[85vh] focus:outline-none" > @@ -134,12 +138,14 @@ export const CloudServiceCredentialList = () => {
- + {provider === 'aws' && + + }
} + {provider === 'gcp' && + + } )} diff --git a/packages/insomnia/src/ui/components/modals/cloud-credential-modal/gcp-credential-form.tsx b/packages/insomnia/src/ui/components/modals/cloud-credential-modal/gcp-credential-form.tsx new file mode 100644 index 00000000000..d924ed02a07 --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/cloud-credential-modal/gcp-credential-form.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import { Button, Input, Label, TextField } from 'react-aria-components'; + +import { type BaseCloudCredential, type CloudProviderCredential, type CloudProviderName } from '../../../../models/cloud-credential'; +import { HelpTooltip } from '../../help-tooltip'; +import { Icon } from '../../icon'; + +export interface GCPCredentialFormProps { + data?: CloudProviderCredential; + onSubmit: (newData: BaseCloudCredential) => void; + isLoading: boolean; + errorMessage?: string; +} +const initialFormValue = { + name: '', +}; +export const providerType: CloudProviderName = 'gcp'; + +export const GCPCredentialForm = (props: GCPCredentialFormProps) => { + const { data, onSubmit, isLoading, errorMessage } = props; + const [inputKeyPath, setInputKeyPath] = useState(data?.credentials as string); + const isEdit = !!data; + const { name } = data || initialFormValue; + + const handleSelectFile = async () => { + const { canceled, filePaths } = await window.dialog.showOpenDialog({ + title: 'Select Service Accont Key File', + buttonLabel: 'Select', + properties: ['openFile'], + filters: [ + { name: 'JSON File', extensions: ['json'] }, + ], + }); + if (canceled) { + return; + } + const selectedFile = filePaths[0]; + setInputKeyPath(selectedFile); + }; + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + const formData = new FormData(e.currentTarget); + const { name } = Object.fromEntries(formData.entries()) as Record; + const newData = { + name, + provider: providerType, + credentials: inputKeyPath!, + }; + onSubmit(newData); + }} + > +
+ + + + +
+ +
+
+ setInputKeyPath(e.target.value)} + /> + +
+ {(errorMessage) && +

{errorMessage}

+ } +
+ +
+
+ + ); +}; diff --git a/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx b/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx index 5625e48b7bc..0a57652c9e6 100644 --- a/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx +++ b/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx @@ -1,7 +1,9 @@ +import type { AuthenticationResult } from '@azure/msal-node'; import React, { useState } from 'react'; import { Button, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components'; import { useFetcher } from 'react-router-dom'; +import { isDevelopment } from '../../../common/constants'; import { type CloudProviderCredential, type CloudProviderName, getProviderDisplayName } from '../../../models/cloud-credential'; import { usePlanData } from '../../hooks/use-plan'; import { useRootLoaderData } from '../../routes/root'; @@ -9,6 +11,8 @@ import { Icon } from '../icon'; import { showModal } from '../modals'; import { AskModal } from '../modals/ask-modal'; import { CloudCredentialModal } from '../modals/cloud-credential-modal/cloud-credential-modal'; +import { SvgIcon } from '../svg-icon'; +import { Tooltip } from '../tooltip'; import { UpgradeNotice } from '../upgrade-notice'; import { NumberSetting } from './number-setting'; @@ -21,19 +25,18 @@ const createCredentialItemList: createCredentialItemType[] = [ { id: 'aws', name: getProviderDisplayName('aws'), - icon: , + icon: , }, { id: 'azure', name: getProviderDisplayName('azure'), - icon: , + icon: , + }, + { + id: 'gcp', + name: getProviderDisplayName('gcp'), + icon: , }, - // TODO support gcp - // { - // id: 'gcp', - // name: getProviderDisplayName('gcp'), - // icon: - // }, ]; const buttonClassName = 'disabled:opacity-50 h-7 aspect-square aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] transition-all text-sm py-1 px-2'; @@ -70,7 +73,12 @@ export const CloudServiceCredentialList = () => { }; const handleCreateCloudServiceCredential = (key: CloudProviderName) => { - setModalState({ show: true, provider: key as CloudProviderName }); + if (key === 'azure' && !isDevelopment()) { + // open Azure oauth page directly in production build + window.main.cloudService.openAuthUrl('azure'); + } else { + setModalState({ show: true, provider: key as CloudProviderName }); + } }; if (!isEnterprisePlan) { @@ -132,18 +140,36 @@ export const CloudServiceCredentialList = () => { {cloudCredentials.map(cloudCred => { - const { _id, name, provider } = cloudCred; + const { _id, name, provider, credentials } = cloudCred; + let isAzureTokenExpired = false; + if (provider === 'azure') { + const tokenExpiresOn = (credentials as AuthenticationResult).expiresOn; + if (tokenExpiresOn && new Date() >= new Date(tokenExpiresOn)) { + isAzureTokenExpired = true; + } + }; + const credentialItem = createCredentialItemList.find(item => item.id === provider); return ( {name} + {provider === 'azure' && isAzureTokenExpired && + + + + } - {getProviderDisplayName(provider!)} + {credentialItem && ( +
+ {credentialItem.icon} + {credentialItem.name} +
+ )}
- {provider === 'aws' && + {provider !== 'azure' && } + {provider === 'azure' && isAzureTokenExpired && + + }