Skip to content

Commit

Permalink
feat(api): expose credentials in GET /integrations/:uniqueKey$
Browse files Browse the repository at this point in the history
  • Loading branch information
bodinsamuel committed Nov 27, 2024
1 parent c61780c commit 35fff17
Show file tree
Hide file tree
Showing 18 changed files with 141 additions and 43 deletions.
48 changes: 46 additions & 2 deletions docs-v2/spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ paths:
type: array
items:
type: string
enum: [webhook]
enum: [webhook, credentials]
description: Include additional sensitive data
responses:
'200':
Expand All @@ -381,7 +381,7 @@ paths:
- data
properties:
data:
$ref: '#/components/schemas/Integration'
$ref: '#/components/schemas/IntegrationFull'
'400':
$ref: '#/components/responses/BadRequest'
'401':
Expand Down Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions packages/persist/lib/server.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/server/lib/clients/oauth1.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,11 +35,16 @@ export const getPublicIntegration = asyncWrapper<GetPublicIntegration>(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) {
Expand All @@ -57,6 +62,25 @@ export const getPublicIntegration = asyncWrapper<GetPublicIntegration>(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 })
Expand Down
6 changes: 3 additions & 3 deletions packages/server/lib/helpers/tba.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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}",` +
Expand Down
16 changes: 11 additions & 5 deletions packages/shared/lib/seeders/config.seeder.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
Expand Down Expand Up @@ -48,7 +48,12 @@ export const createConfigSeeds = async (env: DBEnvironment): Promise<void> => {
);
};

export const createConfigSeed = async (env: DBEnvironment, unique_key: string, providerName: string): Promise<ProviderConfig> => {
export async function createConfigSeed(
env: DBEnvironment,
unique_key: string,
providerName: string,
rest?: Partial<IntegrationConfig>
): Promise<IntegrationConfig> {
const provider = getProvider(providerName);
if (!provider) {
throw new Error(`createConfigSeed: ${providerName} provider not found`);
Expand All @@ -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;
};
}
14 changes: 7 additions & 7 deletions packages/shared/lib/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -121,15 +121,15 @@ class ConfigService {
.filter((config) => config != null) as ProviderConfig[];
}

async createProviderConfig(config: ProviderConfig, provider: Provider): Promise<ProviderConfig | null> {
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<IntegrationConfig | null> {
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<ProviderConfig>(`_nango_configs`).insert(configToInsert).returning('*');
const res = await db.knex.from<IntegrationConfig>(`_nango_configs`).insert(configToInsert).returning('*');
return res[0] ?? null;
}

async createEmptyProviderConfig(providerName: string, environment_id: number, provider: Provider): Promise<ProviderConfig> {
async createEmptyProviderConfig(providerName: string, environment_id: number, provider: Provider): Promise<IntegrationConfig> {
const exists = await db.knex
.count<{ count: string }>('*')
.from<ProviderConfig>(`_nango_configs`)
Expand All @@ -141,7 +141,7 @@ class ConfigService {
environment_id,
unique_key: exists?.count === '0' ? providerName : `${providerName}-${nanoid(4).toLocaleLowerCase()}`,
provider: providerName
} as ProviderConfig,
},
provider
);

Expand Down
12 changes: 6 additions & 6 deletions packages/shared/lib/services/connection.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -979,7 +979,7 @@ class ConnectionService {
},
environment,
provider,
config: integration,
config: integration as ProviderConfig,
action: 'token_refresh'
});
}
Expand All @@ -996,15 +996,15 @@ class ConnectionService {
await onRefreshSuccess({
connection,
environment,
config: integration
config: integration as ProviderConfig
});
}

copy.credentials = response.credentials as OAuth2Credentials;
} 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,
Expand Down Expand Up @@ -1039,7 +1039,7 @@ class ConnectionService {
},
environment,
provider,
config: integration,
config: integration as ProviderConfig,
action: 'connection_test'
});

Expand All @@ -1054,7 +1054,7 @@ class ConnectionService {
await onRefreshSuccess({
connection,
environment,
config: integration
config: integration as ProviderConfig
});
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/types/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
/**
Expand Down
2 changes: 0 additions & 2 deletions packages/types/lib/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ export interface ApiKeyCredentials {
apiKey: string;
}

export type AuthCredentials = OAuth2Credentials | OAuth1Credentials | OAuth2ClientCredentials;

export interface AppCredentials {
type: AuthModes['App'];
access_token: string;
Expand Down
9 changes: 7 additions & 2 deletions packages/types/lib/integration/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -14,6 +14,10 @@ export type ApiPublicIntegration = Merge<Pick<IntegrationConfig, 'created_at' |
} & ApiPublicIntegrationInclude;
export interface ApiPublicIntegrationInclude {
webhook_url?: string | null;
credentials?:
| { type: AuthModes['OAuth2'] | AuthModes['OAuth1'] | AuthModes['TBA']; client_id: string | null; client_secret: string | null; scopes: string | null }
| { type: AuthModes['App']; app_id: string | null; private_key: string | null; app_link: string | null }
| null;
}

export type GetPublicListIntegrationsLegacy = Endpoint<{
Expand All @@ -27,6 +31,7 @@ export type GetPublicListIntegrationsLegacy = Endpoint<{
export type GetPublicListIntegrations = Endpoint<{
Method: 'GET';
Path: '/integrations';
Querystring?: { connect_session_token: string };
Success: {
data: ApiPublicIntegration[];
};
Expand All @@ -36,7 +41,7 @@ export type GetPublicIntegration = Endpoint<{
Method: 'GET';
Path: '/integrations/:uniqueKey';
Params: { uniqueKey: string };
Querystring: { include?: 'webhook'[] | undefined };
Querystring: { include?: ('webhook' | 'credentials')[] | undefined };
Success: { data: ApiPublicIntegration };
}>;

Expand Down
12 changes: 8 additions & 4 deletions packages/types/lib/integration/db.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | undefined;
custom?: Record<string, string> | undefined | null;
missing_fields: string[];
}

export type DBCreateIntegration = SetOptional<NullablePartial<Omit<IntegrationConfig, 'created_at' | 'updated_at'>>, 'missing_fields'>;
Loading

0 comments on commit 35fff17

Please sign in to comment.