From 35fff176c9ffdaf97fb668a009b94c1423dfce2b Mon Sep 17 00:00:00 2001 From: Samuel Bodin <1637651+bodinsamuel@users.noreply.github.com> Date: Wed, 27 Nov 2024 18:06:18 +0100 Subject: [PATCH] feat(api): expose credentials in GET /integrations/:uniqueKey$ --- docs-v2/spec.yaml | 48 ++++++++++++++++++- .../persist/lib/server.integration.test.ts | 2 - packages/server/lib/clients/oauth1.client.ts | 4 +- .../getIntegration.integration.test.ts | 11 +++++ .../integrations/uniqueKey/getIntegration.ts | 28 ++++++++++- packages/server/lib/helpers/tba.ts | 6 +-- packages/shared/lib/seeders/config.seeder.ts | 16 +++++-- .../shared/lib/services/config.service.ts | 14 +++--- .../shared/lib/services/connection.service.ts | 12 ++--- packages/types/lib/api.ts | 3 +- packages/types/lib/auth/api.ts | 2 - packages/types/lib/integration/api.ts | 9 +++- packages/types/lib/integration/db.ts | 12 +++-- packages/types/lib/providers/api.ts | 3 +- packages/types/lib/utils.ts | 6 +++ .../Settings/components/App.tsx | 2 +- .../Settings/components/Custom.tsx | 4 +- .../Settings/components/OAuth.tsx | 2 +- 18 files changed, 141 insertions(+), 43 deletions(-) diff --git a/docs-v2/spec.yaml b/docs-v2/spec.yaml index c267a3e663..2e9ba6cc6f 100644 --- a/docs-v2/spec.yaml +++ b/docs-v2/spec.yaml @@ -368,7 +368,7 @@ paths: type: array items: type: string - enum: [webhook] + enum: [webhook, credentials] description: Include additional sensitive data responses: '200': @@ -381,7 +381,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Integration' + $ref: '#/components/schemas/IntegrationFull' '400': $ref: '#/components/responses/BadRequest' '401': @@ -2077,6 +2077,50 @@ components: type: string format: date description: Last time it was updated + IntegrationFull: + allOf: + - $ref: '#/components/schemas/Integration' + - type: object + additionalProperties: false + properties: + webhook_url: + type: string + description: Configure your integration webhooks to point to this URL + nullable: true + credentials: + nullable: true + oneOf: + - type: object + additionalProperties: false + properties: + type: + type: string + enum: ['OAUTH2', 'OAUTH1', 'TBA'] + client_id: + type: string + nullable: true + client_secret: + type: string + nullable: true + scopes: + type: string + nullable: true + + - type: object + additionalProperties: false + properties: + type: + type: string + enum: ['APP'] + app_id: + type: string + nullable: true + private_key: + type: string + nullable: true + app_link: + type: string + nullable: true ConnectSession: type: object diff --git a/packages/persist/lib/server.integration.test.ts b/packages/persist/lib/server.integration.test.ts index 249ce47ac2..010c802551 100644 --- a/packages/persist/lib/server.integration.test.ts +++ b/packages/persist/lib/server.integration.test.ts @@ -277,8 +277,6 @@ const initDb = async () => { environment_id: env.id, oauth_client_id: '', oauth_client_secret: '', - created_at: now, - updated_at: now, missing_fields: [] }, googleProvider diff --git a/packages/server/lib/clients/oauth1.client.ts b/packages/server/lib/clients/oauth1.client.ts index 7485de8d86..f8c472fa81 100644 --- a/packages/server/lib/clients/oauth1.client.ts +++ b/packages/server/lib/clients/oauth1.client.ts @@ -28,8 +28,8 @@ export class OAuth1Client { this.client = new oAuth1.OAuth( this.authConfig.request_url, typeof this.authConfig.token_url === 'string' ? this.authConfig.token_url : (this.authConfig.token_url?.['OAUTH1'] as string), - this.config.oauth_client_id, - this.config.oauth_client_secret, + this.config.oauth_client_id!, + this.config.oauth_client_secret!, '1.0', callbackUrl, this.authConfig.signature_method, diff --git a/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.integration.test.ts b/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.integration.test.ts index 5b554704ff..e28bafb65c 100644 --- a/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.integration.test.ts +++ b/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.integration.test.ts @@ -100,4 +100,15 @@ describe(`GET ${endpoint}`, () => { isError(res.json); expect(res.res.status).toBe(404); }); + + it('should get credentials', async () => { + const env = await seeders.createEnvironmentSeed(); + await seeders.createConfigSeed(env, 'github', 'github', { oauth_client_id: 'foo', oauth_client_secret: 'bar', oauth_scopes: 'hello, world' }); + + const res = await api.fetch(endpoint, { method: 'GET', token: env.secret_key, params: { uniqueKey: 'github' }, query: { include: ['credentials'] } }); + + isSuccess(res.json); + expect(res.res.status).toBe(200); + expect(res.json.data.credentials).toStrictEqual({ client_id: 'foo', client_secret: 'bar', scopes: 'hello, world', type: 'OAUTH2' }); + }); }); diff --git a/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.ts b/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.ts index 30047ad636..27c7f99b68 100644 --- a/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.ts +++ b/packages/server/lib/controllers/integrations/uniqueKey/getIntegration.ts @@ -12,7 +12,7 @@ export const validationParams = z }) .strict(); -const valInclude = z.enum(['webhook']); +const valInclude = z.enum(['webhook', 'credentials']); const validationQuery = z .object({ include: z @@ -35,11 +35,16 @@ export const getPublicIntegration = asyncWrapper(async (re return; } - const { environment } = res.locals; + const { environment, authType } = res.locals; const params: GetPublicIntegration['Params'] = valParams.data; const query: GetPublicIntegration['Querystring'] = valQuery.data; const queryInclude = new Set(query.include || []); + if (queryInclude.size > 0 && authType !== 'secretKey') { + // This is not allowed right now anyway but it's too prevent any mistakes + res.status(403).send({ error: { code: 'invalid_permissions', message: "Can't include credentials without a private key" } }); + return; + } const integration = await configService.getProviderConfig(params.uniqueKey, environment.id); if (!integration) { @@ -57,6 +62,25 @@ export const getPublicIntegration = asyncWrapper(async (re if (queryInclude.has('webhook')) { include.webhook_url = provider.webhook_routing_script ? `${getGlobalWebhookReceiveUrl()}/${environment.uuid}/${integration.provider}` : null; } + if (queryInclude.has('credentials')) { + if (provider.auth_mode === 'OAUTH1' || provider.auth_mode === 'OAUTH2' || provider.auth_mode === 'TBA') { + include.credentials = { + type: provider.auth_mode, + client_id: integration.oauth_client_id, + client_secret: integration.oauth_client_secret, + scopes: integration.oauth_scopes || null + }; + } else if (provider.auth_mode === 'APP') { + include.credentials = { + type: provider.auth_mode, + app_id: integration.oauth_client_id, + private_key: integration.oauth_client_secret, + app_link: integration.app_link || null + }; + } else { + include.credentials = null; + } + } res.status(200).send({ data: integrationToPublicApi({ integration, include, provider }) diff --git a/packages/server/lib/helpers/tba.ts b/packages/server/lib/helpers/tba.ts index 852b9c6251..fa8669bd4c 100644 --- a/packages/server/lib/helpers/tba.ts +++ b/packages/server/lib/helpers/tba.ts @@ -32,7 +32,7 @@ export async function makeAccessTokenRequest({ const { nonce, timestamp } = getTbaMetaParams(); const oauthParams = { - oauth_consumer_key: config.oauth_client_id, + oauth_consumer_key: config.oauth_client_id || '', oauth_nonce: nonce, oauth_signature_method: SIGNATURE_METHOD, oauth_timestamp: timestamp.toString(), @@ -50,12 +50,12 @@ export async function makeAccessTokenRequest({ const hash = generateSignature({ baseString, - clientSecret: config.oauth_client_secret, + clientSecret: config.oauth_client_secret || '', tokenSecret: oauth_token_secret as string }); const authHeader = - `OAuth oauth_consumer_key="${percentEncode(config.oauth_client_id)}",` + + `OAuth oauth_consumer_key="${percentEncode(config.oauth_client_id || '')}",` + `oauth_token="${oauth_token}",` + `oauth_verifier="${oauth_verifier}",` + `oauth_nonce="${nonce}",` + diff --git a/packages/shared/lib/seeders/config.seeder.ts b/packages/shared/lib/seeders/config.seeder.ts index be5da1ff82..c2445b7058 100644 --- a/packages/shared/lib/seeders/config.seeder.ts +++ b/packages/shared/lib/seeders/config.seeder.ts @@ -1,6 +1,6 @@ import configService from '../services/config.service.js'; import type { Config as ProviderConfig } from '../models/Provider.js'; -import type { DBEnvironment } from '@nangohq/types'; +import type { DBEnvironment, IntegrationConfig } from '@nangohq/types'; import { getProvider } from '../services/providers.js'; export const createConfigSeeds = async (env: DBEnvironment): Promise => { @@ -48,7 +48,12 @@ export const createConfigSeeds = async (env: DBEnvironment): Promise => { ); }; -export const createConfigSeed = async (env: DBEnvironment, unique_key: string, providerName: string): Promise => { +export async function createConfigSeed( + env: DBEnvironment, + unique_key: string, + providerName: string, + rest?: Partial +): Promise { const provider = getProvider(providerName); if (!provider) { throw new Error(`createConfigSeed: ${providerName} provider not found`); @@ -58,12 +63,13 @@ export const createConfigSeed = async (env: DBEnvironment, unique_key: string, p { unique_key, provider: providerName, - environment_id: env.id - } as ProviderConfig, + environment_id: env.id, + ...rest + }, provider ); if (!created) { throw new Error('failed to created to provider config'); } return created; -}; +} diff --git a/packages/shared/lib/services/config.service.ts b/packages/shared/lib/services/config.service.ts index 2b022d1763..5cabb57d34 100644 --- a/packages/shared/lib/services/config.service.ts +++ b/packages/shared/lib/services/config.service.ts @@ -8,7 +8,7 @@ import syncManager from './sync/manager.service.js'; import { deleteSyncFilesForConfig, deleteByConfigId as deleteSyncConfigByConfigId } from '../services/sync/config/config.service.js'; import environmentService from '../services/environment.service.js'; import type { Orchestrator } from '../clients/orchestrator.js'; -import type { AuthModeType, Provider } from '@nangohq/types'; +import type { AuthModeType, DBCreateIntegration, IntegrationConfig, Provider } from '@nangohq/types'; import { getProvider } from './providers.js'; interface ValidationRule { @@ -121,15 +121,15 @@ class ConfigService { .filter((config) => config != null) as ProviderConfig[]; } - async createProviderConfig(config: ProviderConfig, provider: Provider): Promise { - const configToInsert = config.oauth_client_secret ? encryptionManager.encryptProviderConfig(config) : config; - configToInsert.missing_fields = this.validateProviderConfig(provider.auth_mode, config); + async createProviderConfig(config: DBCreateIntegration, provider: Provider): Promise { + const configToInsert = config.oauth_client_secret ? encryptionManager.encryptProviderConfig(config as ProviderConfig) : config; + configToInsert.missing_fields = this.validateProviderConfig(provider.auth_mode, config as ProviderConfig); - const res = await db.knex.from(`_nango_configs`).insert(configToInsert).returning('*'); + const res = await db.knex.from(`_nango_configs`).insert(configToInsert).returning('*'); return res[0] ?? null; } - async createEmptyProviderConfig(providerName: string, environment_id: number, provider: Provider): Promise { + async createEmptyProviderConfig(providerName: string, environment_id: number, provider: Provider): Promise { const exists = await db.knex .count<{ count: string }>('*') .from(`_nango_configs`) @@ -141,7 +141,7 @@ class ConfigService { environment_id, unique_key: exists?.count === '0' ? providerName : `${providerName}-${nanoid(4).toLocaleLowerCase()}`, provider: providerName - } as ProviderConfig, + }, provider ); diff --git a/packages/shared/lib/services/connection.service.ts b/packages/shared/lib/services/connection.service.ts index 5b55c434c0..61b843eb36 100644 --- a/packages/shared/lib/services/connection.service.ts +++ b/packages/shared/lib/services/connection.service.ts @@ -949,7 +949,7 @@ class ConnectionService { const { success, error, response } = await this.refreshCredentialsIfNeeded({ connectionId: connection.connection_id, environmentId: environment.id, - providerConfig: integration, + providerConfig: integration as ProviderConfig, provider: provider as ProviderOAuth2, environment_id: environment.id, instantRefresh @@ -979,7 +979,7 @@ class ConnectionService { }, environment, provider, - config: integration, + config: integration as ProviderConfig, action: 'token_refresh' }); } @@ -996,7 +996,7 @@ class ConnectionService { await onRefreshSuccess({ connection, environment, - config: integration + config: integration as ProviderConfig }); } @@ -1004,7 +1004,7 @@ class ConnectionService { } else if (connection.credentials?.type === 'BASIC' || connection.credentials?.type === 'API_KEY' || connection.credentials?.type === 'TBA') { if (connectionTestHook) { const result = await connectionTestHook({ - config: integration, + config: integration as ProviderConfig, provider, connectionConfig: connection.connection_config, connectionId: connection.connection_id, @@ -1039,7 +1039,7 @@ class ConnectionService { }, environment, provider, - config: integration, + config: integration as ProviderConfig, action: 'connection_test' }); @@ -1054,7 +1054,7 @@ class ConnectionService { await onRefreshSuccess({ connection, environment, - config: integration + config: integration as ProviderConfig }); } } diff --git a/packages/types/lib/api.ts b/packages/types/lib/api.ts index 71136053ac..14482857ec 100644 --- a/packages/types/lib/api.ts +++ b/packages/types/lib/api.ts @@ -25,7 +25,8 @@ export type ResDefaultErrors = | ApiError<'malformed_auth_header'> | ApiError<'unknown_account'> | ApiError<'unknown_connect_session_token'> - | ApiError<'invalid_cli_version'>; + | ApiError<'invalid_cli_version'> + | ApiError<'invalid_permissions'>; export type EndpointMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; /** diff --git a/packages/types/lib/auth/api.ts b/packages/types/lib/auth/api.ts index 94b38a6cb7..7706aa9b4b 100644 --- a/packages/types/lib/auth/api.ts +++ b/packages/types/lib/auth/api.ts @@ -68,8 +68,6 @@ export interface ApiKeyCredentials { apiKey: string; } -export type AuthCredentials = OAuth2Credentials | OAuth1Credentials | OAuth2ClientCredentials; - export interface AppCredentials { type: AuthModes['App']; access_token: string; diff --git a/packages/types/lib/integration/api.ts b/packages/types/lib/integration/api.ts index 53b7c57cd1..323429c124 100644 --- a/packages/types/lib/integration/api.ts +++ b/packages/types/lib/integration/api.ts @@ -2,7 +2,7 @@ import type { Merge } from 'type-fest'; import type { ApiTimestamps, Endpoint } from '../api'; import type { IntegrationConfig } from './db'; import type { Provider } from '../providers/provider'; -import type { AuthModeType } from '../auth/api'; +import type { AuthModeType, AuthModes } from '../auth/api'; import type { NangoModel, NangoSyncEndpointV2, ScriptTypeLiteral } from '../nangoYaml'; import type { LegacySyncModelSchema, NangoConfigMetadata } from '../deploy/incomingFlow'; import type { JSONSchema7 } from 'json-schema'; @@ -14,6 +14,10 @@ export type ApiPublicIntegration = Merge; diff --git a/packages/types/lib/integration/db.ts b/packages/types/lib/integration/db.ts index 91341fce34..9854026b70 100644 --- a/packages/types/lib/integration/db.ts +++ b/packages/types/lib/integration/db.ts @@ -1,16 +1,20 @@ +import type { SetOptional } from 'type-fest'; import type { TimestampsAndDeleted } from '../db.js'; +import type { NullablePartial } from '../utils.js'; export interface IntegrationConfig extends TimestampsAndDeleted { id?: number | undefined; unique_key: string; provider: string; - oauth_client_id: string; - oauth_client_secret: string; - oauth_scopes?: string | undefined; + oauth_client_id: string | null; + oauth_client_secret: string | null; + oauth_scopes?: string | undefined | null; environment_id: number; oauth_client_secret_iv?: string | null; oauth_client_secret_tag?: string | null; app_link?: string | null | undefined; - custom?: Record | undefined; + custom?: Record | undefined | null; missing_fields: string[]; } + +export type DBCreateIntegration = SetOptional>, 'missing_fields'>; diff --git a/packages/types/lib/providers/api.ts b/packages/types/lib/providers/api.ts index 1da83c0284..11194e9a44 100644 --- a/packages/types/lib/providers/api.ts +++ b/packages/types/lib/providers/api.ts @@ -4,7 +4,7 @@ import type { Provider } from './provider'; export type GetPublicProviders = Endpoint<{ Method: 'GET'; Path: `/providers`; - Querystring: { search?: string | undefined }; + Querystring: { search?: string | undefined; connect_session_token?: string }; Success: { data: ApiProvider[]; }; @@ -15,6 +15,7 @@ export type GetPublicProvider = Endpoint<{ Method: 'GET'; Path: `/providers/:provider`; Params: { provider: string }; + Querystring?: { connect_session_token: string }; Success: { data: ApiProvider; }; diff --git a/packages/types/lib/utils.ts b/packages/types/lib/utils.ts index 69bd2ffff7..febdc03401 100644 --- a/packages/types/lib/utils.ts +++ b/packages/types/lib/utils.ts @@ -12,3 +12,9 @@ export interface Logger { type ValidateSelection = U extends T ? U : never; export type PickFromUnion = ValidateSelection; + +export type NullablePartial< + TBase, + NK extends keyof TBase = { [K in keyof TBase]: null extends TBase[K] ? K : never }[keyof TBase], + NP = Partial> & Pick> +> = { [K in keyof NP]: NP[K] }; diff --git a/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/App.tsx b/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/App.tsx index 254487858d..bdda7de986 100644 --- a/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/App.tsx +++ b/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/App.tsx @@ -62,7 +62,7 @@ export const SettingsApp: React.FC<{ data: GetIntegration['Success']['data']; en required minLength={1} variant={'flat'} - after={} + after={} /> diff --git a/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/Custom.tsx b/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/Custom.tsx index 91b9443bdb..94b85c337c 100644 --- a/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/Custom.tsx +++ b/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/Custom.tsx @@ -63,7 +63,7 @@ export const SettingsCustom: React.FC<{ data: GetIntegration['Success']['data']; required minLength={1} variant={'flat'} - after={} + after={} /> @@ -94,7 +94,7 @@ export const SettingsCustom: React.FC<{ data: GetIntegration['Success']['data']; required minLength={1} variant={'flat'} - after={} + after={} /> diff --git a/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/OAuth.tsx b/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/OAuth.tsx index b735d2a0c9..75ede7be29 100644 --- a/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/OAuth.tsx +++ b/packages/webapp/src/pages/Integrations/providerConfigKey/Settings/components/OAuth.tsx @@ -61,7 +61,7 @@ export const SettingsOAuth: React.FC<{ data: GetIntegration['Success']['data']; required minLength={1} variant={'flat'} - after={} + after={} />