diff --git a/packages/frontend/lib/index.ts b/packages/frontend/lib/index.ts index f44242c971..9448d3f1ec 100644 --- a/packages/frontend/lib/index.ts +++ b/packages/frontend/lib/index.ts @@ -99,9 +99,14 @@ export default class Nango { public auth( providerConfigKey: string, connectionId: string, - options?: (ConnectionConfig | BasicApiCredentials | ApiKeyCredentials | AppStoreCredentials) & AuthOptions + options?: (ConnectionConfig | OAuthCredentialsOverride | BasicApiCredentials | ApiKeyCredentials | AppStoreCredentials) & AuthOptions ): Promise { - if (options && 'credentials' in options && Object.keys(options.credentials).length > 0) { + if ( + options && + 'credentials' in options && + (!('oauth_client_id' in options.credentials) || !('oauth_client_secret' in options.credentials)) && + Object.keys(options.credentials).length > 0 + ) { const credentials = options.credentials as BasicApiCredentials | ApiKeyCredentials; const { credentials: _, ...connectionConfig } = options as ConnectionConfig; @@ -311,6 +316,14 @@ export default class Nango { query.push(`user_scope=${connectionConfig.user_scope.join(',')}`); } + if (connectionConfig.credentials) { + const credentials = connectionConfig.credentials; + if ('oauth_client_id' in credentials && 'oauth_client_secret' in credentials) { + query.push(`credentials[oauth_client_id]=${credentials.oauth_client_id}`); + query.push(`credentials[oauth_client_secret]=${credentials.oauth_client_secret}`); + } + } + for (const param in connectionConfig.authorization_params) { const val = connectionConfig.authorization_params[param]; if (typeof val === 'string') { @@ -330,7 +343,12 @@ interface ConnectionConfig { hmac?: string; user_scope?: string[]; authorization_params?: Record; - credentials?: BasicApiCredentials | ApiKeyCredentials | AppStoreCredentials; + credentials?: OAuthCredentialsOverride | BasicApiCredentials | ApiKeyCredentials | AppStoreCredentials; +} + +interface OAuthCredentialsOverride { + oauth_client_id: string; + oauth_client_secret: string; } interface BasicApiCredentials { diff --git a/packages/server/lib/controllers/connection.controller.ts b/packages/server/lib/controllers/connection.controller.ts index abbcb59dc9..03e4b2e20e 100644 --- a/packages/server/lib/controllers/connection.controller.ts +++ b/packages/server/lib/controllers/connection.controller.ts @@ -558,6 +558,26 @@ class ConnectionController { raw: req.body.raw || req.body }; + if (req.body['oauth_client_id']) { + oAuthCredentials.config_override = { + client_id: req.body['oauth_client_id'] + }; + } + + if (req.body['oauth_client_secret']) { + oAuthCredentials.config_override = { + ...oAuthCredentials.config_override, + client_secret: req.body['oauth_client_secret'] + }; + } + + if (req.body['oauth_scopes']) { + oAuthCredentials.config_override = { + ...oAuthCredentials.config_override, + scopes: Array.isArray(req.body['oauth_scopes']) ? req.body['oauth_scopes'].join(',') : req.body['oauth_scopes'] + }; + } + const [imported] = await connectionService.importOAuthConnection( connection_id, provider_config_key, diff --git a/packages/server/lib/controllers/oauth.controller.ts b/packages/server/lib/controllers/oauth.controller.ts index e608646c9c..0cc7fbbdc6 100644 --- a/packages/server/lib/controllers/oauth.controller.ts +++ b/packages/server/lib/controllers/oauth.controller.ts @@ -16,7 +16,7 @@ import type { TemplateOAuth2 as ProviderTemplateOAuth2, OAuthSession, OAuth1RequestTokenResult, - AuthCredentials, + OAuth2Credentials, ConnectionConfig } from '@nangohq/shared'; import { @@ -95,6 +95,7 @@ class OAuthController { const callbackUrl = await getOauthCallbackUrl(environmentId); const connectionConfig = req.query['params'] != null ? getConnectionConfig(req.query['params']) : {}; const authorizationParams = req.query['authorization_params'] != null ? getAdditionalAuthorizationParams(req.query['authorization_params']) : {}; + const overrideCredentials = req.query['credentials'] != null ? getAdditionalAuthorizationParams(req.query['credentials']) : {}; if (connectionId == null) { await createActivityLogMessageAndEnd({ @@ -214,6 +215,24 @@ class OAuthController { await updateSessionIdActivityLog(activityLogId as number, session.id); + // certain providers need the credentials to be specified in the config + if (overrideCredentials) { + if (overrideCredentials['oauth_client_id'] && overrideCredentials['oauth_client_secret']) { + config.oauth_client_id = overrideCredentials['oauth_client_id']; + config.oauth_client_secret = overrideCredentials['oauth_client_secret']; + + session.connectionConfig = { + ...session.connectionConfig, + oauth_client_id: config.oauth_client_id, + oauth_client_secret: config.oauth_client_secret + }; + } + } + + if (connectionConfig['oauth_scopes']) { + config.oauth_scopes = connectionConfig['oauth_scopes']; + } + if (config?.oauth_client_id == null || config?.oauth_client_secret == null || config.oauth_scopes == null) { await createActivityLogMessageAndEnd({ level: 'error', @@ -852,6 +871,16 @@ class OAuthController { return publisher.notifySuccess(res, channel, providerConfigKey, connectionId); } + // check for oauth overrides in the connnection config + if (session.connectionConfig['oauth_client_id'] && session.connectionConfig['oauth_client_secret']) { + config.oauth_client_id = session.connectionConfig['oauth_client_id']; + config.oauth_client_secret = session.connectionConfig['oauth_client_secret']; + } + + if (session.connectionConfig['oauth_scopes']) { + config.oauth_scopes = session.connectionConfig['oauth_scopes']; + } + const simpleOAuthClient = new simpleOauth2.AuthorizationCode(oauth2Client.getSimpleOAuth2ClientConfig(config, template, session.connectionConfig)); let additionalTokenParams: Record = {}; @@ -919,10 +948,10 @@ class OAuthController { const tokenMetadata = getConnectionMetadataFromTokenResponse(rawCredentials, template); - let parsedRawCredentials: AuthCredentials; + let parsedRawCredentials: OAuth2Credentials; try { - parsedRawCredentials = connectionService.parseRawCredentials(rawCredentials, ProviderAuthModes.OAuth2); + parsedRawCredentials = connectionService.parseRawCredentials(rawCredentials, ProviderAuthModes.OAuth2) as OAuth2Credentials; } catch { await createActivityLogMessageAndEnd({ level: 'error', @@ -989,6 +1018,40 @@ class OAuthController { }; } + if (connectionConfig['oauth_client_id'] && connectionConfig['oauth_client_secret']) { + parsedRawCredentials = { + ...parsedRawCredentials, + config_override: { + client_id: connectionConfig['oauth_client_id'], + client_secret: connectionConfig['oauth_client_secret'] + } + }; + + connectionConfig = Object.keys(session.connectionConfig).reduce((acc: Record, key: string) => { + if (key !== 'oauth_client_id' && key !== 'oauth_client_secret') { + acc[key] = connectionConfig[key] as string; + } + return acc; + }, {}); + } + + if (connectionConfig['oauth_scopes']) { + parsedRawCredentials = { + ...parsedRawCredentials, + config_override: { + ...parsedRawCredentials.config_override, + scopes: Array.isArray(connectionConfig['oauth_scopes']) ? connectionConfig['oauth_scopes'].join(',') : connectionConfig['oauth_scopes'] + } + }; + + connectionConfig = Object.keys(session.connectionConfig).reduce((acc: Record, key: string) => { + if (key !== 'oauth_scopes') { + acc[key] = connectionConfig[key] as string; + } + return acc; + }, {}); + } + const [updatedConnection] = await connectionService.upsertConnection( connectionId, providerConfigKey, diff --git a/packages/shared/lib/models/Auth.ts b/packages/shared/lib/models/Auth.ts index 68980e5ef0..932dc25793 100644 --- a/packages/shared/lib/models/Auth.ts +++ b/packages/shared/lib/models/Auth.ts @@ -129,6 +129,12 @@ export interface OAuth2Credentials extends CredentialsCommon { refresh_token?: string; expires_at?: Date | undefined; + + config_override?: { + client_id?: string; + client_secret?: string; + scopes?: string; + }; } export interface OAuth1Credentials extends CredentialsCommon { diff --git a/packages/webapp/src/components/ui/input/TagsInput.tsx b/packages/webapp/src/components/ui/input/TagsInput.tsx index 8f4ddace4e..6cc9aaff3f 100644 --- a/packages/webapp/src/components/ui/input/TagsInput.tsx +++ b/packages/webapp/src/components/ui/input/TagsInput.tsx @@ -32,6 +32,8 @@ const TagsInput = forwardRef(function TagsInpu const [scopes, setScopes] = useState(selectedScopes); + const readOnly = props.readOnly || false; + useEffect(() => { const selectedScopesStr = JSON.stringify(selectedScopes); const optionalSelectedScopesStr = JSON.stringify(optionalSelectedScopes); @@ -88,26 +90,30 @@ const TagsInput = forwardRef(function TagsInpu return ( <> -
- - setEnteredValue(e.currentTarget.value)} - onKeyDown={handleEnter} - placeholder={`${scopes.length ? '' : 'Find the list of scopes in the documentation of the external API provider.'}`} - className="border-border-gray bg-active-gray text-white focus:border-white focus:ring-white block w-full appearance-none rounded-md border px-3 py-0.5 text-sm placeholder-gray-400 shadow-sm focus:outline-none" - /> -
- {error &&

{error}

} - {enteredValue !== '' && ( -
- - Add new scope: "{enteredValue}" -
+ {!readOnly && ( + <> +
+ + setEnteredValue(e.currentTarget.value)} + onKeyDown={handleEnter} + placeholder={`${scopes.length ? '' : 'Find the list of scopes in the documentation of the external API provider.'}`} + className="border-border-gray bg-active-gray text-white focus:border-white focus:ring-white block w-full appearance-none rounded-md border px-3 py-0.5 text-sm placeholder-gray-400 shadow-sm focus:outline-none" + /> +
+ {error &&

{error}

} + {enteredValue !== '' && ( +
+ + Add new scope: "{enteredValue}" +
+ )} + )} {Boolean(scopes.length) && (
@@ -115,10 +121,10 @@ const TagsInput = forwardRef(function TagsInpu return ( {selectedScope} - removeScope(selectedScope)} className="h-5 w-5" /> + {!readOnly && removeScope(selectedScope)} className="h-5 w-5" />} ); })} diff --git a/packages/webapp/src/pages/Connection/Authorization.tsx b/packages/webapp/src/pages/Connection/Authorization.tsx index 47040fb3ac..6ecd67f653 100644 --- a/packages/webapp/src/pages/Connection/Authorization.tsx +++ b/packages/webapp/src/pages/Connection/Authorization.tsx @@ -3,10 +3,12 @@ import { Prism } from '@mantine/prism'; import { Loading } from '@geist-ui/core'; import PrismPlus from '../../components/ui/prism/PrismPlus'; -import { Connection, AuthModes } from '../../types'; +import { AuthModes } from '../../types'; +import type { Connection } from '../../types'; import { formatDateToShortUSFormat } from '../../utils/utils'; import SecretInput from '../../components/ui/input/SecretInput'; import CopyButton from '../../components/ui/button/CopyButton'; +import TagsInput from '../../components/ui/input/TagsInput'; interface AuthorizationProps { connection: Connection | null; @@ -47,7 +49,7 @@ export default function Authorization(props: AuthorizationProps) { Auth Type {connection?.oauthType}
- {connection?.oauthType === AuthModes.ApiKey && ( + {connection?.credentials && connection?.oauthType === AuthModes.ApiKey && 'apiKey' in connection.credentials && (
{connection?.oauthType} @@ -60,7 +62,7 @@ export default function Authorization(props: AuthorizationProps) {
)} - {connection?.credentials && connection?.oauthType === AuthModes.Basic && ( + {connection?.credentials && connection?.oauthType === AuthModes.Basic && 'password' in connection.credentials && (
{connection?.credentials.username && (
@@ -76,6 +78,35 @@ export default function Authorization(props: AuthorizationProps) { )}
)} + {connection?.credentials && 'config_override' in connection.credentials && ( + <> + {connection?.credentials.config_override.client_id && ( +
+ Client ID + +
+ )} + {connection?.credentials.config_override.client_secret && ( +
+ Client Secret + +
+ )} + {connection.credentials.config_override.scopes && ( +
+ Scopes + +
+ )} + + )} {connection?.accessToken && (
Access Token diff --git a/packages/webapp/src/pages/Connection/Create.tsx b/packages/webapp/src/pages/Connection/Create.tsx index 1c82a009b9..f389ce4636 100644 --- a/packages/webapp/src/pages/Connection/Create.tsx +++ b/packages/webapp/src/pages/Connection/Create.tsx @@ -40,6 +40,7 @@ export default function IntegrationCreate() { const [authorizationParams, setAuthorizationParams] = useState | null>(null); const [authorizationParamsError, setAuthorizationParamsError] = useState(false); const [selectedScopes, addToScopesSet, removeFromSelectedSet] = useSet(); + const [oauthSelectedScopes, oauthAddToScopesSet, oauthRemoveFromSelectedSet] = useSet(); const [publicKey, setPublicKey] = useState(''); const [hostUrl, setHostUrl] = useState(''); const [websocketsPath, setWebsocketsPath] = useState(''); @@ -49,6 +50,8 @@ export default function IntegrationCreate() { const [apiKey, setApiKey] = useState(''); const [apiAuthUsername, setApiAuthUsername] = useState(''); const [apiAuthPassword, setApiAuthPassword] = useState(''); + const [optionalOAuthClientId, setOptionalOAuthClientId] = useState(''); + const [optionalOAuthClientSecret, setOptionalOAuthClientSecret] = useState(''); const [privateKeyId, setPrivateKeyId] = useState(''); const [privateKey, setPrivateKey] = useState(''); const [issuerId, setIssuerId] = useState(''); @@ -124,6 +127,7 @@ export default function IntegrationCreate() { const nango = new Nango({ host: hostUrl, websocketsPath, publicKey }); let credentials = {}; + let params = connectionConfigParams || {}; if (authMode === AuthModes.Basic) { credentials = { @@ -146,9 +150,23 @@ export default function IntegrationCreate() { }; } + if (authMode === AuthModes.OAuth2) { + credentials = { + oauth_client_id: optionalOAuthClientId, + oauth_client_secret: optionalOAuthClientSecret + }; + + if (oauthSelectedScopes.length > 0) { + params = { + ...params, + oauth_scopes: oauthSelectedScopes.join(',') + }; + } + } + nango[authMode === AuthModes.None ? 'create' : 'auth'](target.integration_unique_key.value, target.connection_id.value, { user_scope: selectedScopes || [], - params: connectionConfigParams || {}, + params, authorization_params: authorizationParams || {}, hmac: hmacDigest || '', credentials @@ -231,11 +249,29 @@ export default function IntegrationCreate() { // Iterate of connection config params and create a string. if (connectionConfigParams != null && Object.keys(connectionConfigParams).length >= 0) { connectionConfigParamsStr = 'params: { '; + let hasAnyValue = false; for (const [key, value] of Object.entries(connectionConfigParams)) { - connectionConfigParamsStr += `${key}: '${value}', `; + if (value !== '') { + connectionConfigParamsStr += `${key}: '${value}', `; + hasAnyValue = true; + } } connectionConfigParamsStr = connectionConfigParamsStr.slice(0, -2); connectionConfigParamsStr += ' }'; + if (!hasAnyValue) { + connectionConfigParamsStr = ''; + } + } + + if (authMode === AuthModes.OAuth2 && oauthSelectedScopes.length > 0) { + if (connectionConfigParamsStr) { + connectionConfigParamsStr += ', '; + } else { + connectionConfigParamsStr = 'params: { '; + } + connectionConfigParamsStr += `oauth_scopes: '${oauthSelectedScopes.join(',')}', `; + connectionConfigParamsStr = connectionConfigParamsStr.slice(0, -2); + connectionConfigParamsStr += ' }'; } let authorizationParamsStr = ''; @@ -272,7 +308,8 @@ export default function IntegrationCreate() { apiAuthString = ` credentials: { apiKey: '${apiKey}' -}`; + } + `; } if (integration?.authMode === AuthModes.Basic) { @@ -280,7 +317,8 @@ export default function IntegrationCreate() { credentials: { username: '${apiAuthUsername}', password: '${apiAuthPassword}' -}`; + } + `; } let appStoreAuthString = ''; @@ -291,25 +329,46 @@ export default function IntegrationCreate() { privateKeyId: '${privateKeyId}', issuerId: '${issuerId}', privateKey: '${privateKey}' -}`; + } + `; + } + + let oauthCredentialsString = ''; + + if (integration?.authMode === AuthModes.OAuth2 && optionalOAuthClientId && optionalOAuthClientSecret) { + oauthCredentialsString = ` + credentials: { + oauth_client_id: '${optionalOAuthClientId}', + oauth_client_secret: '${optionalOAuthClientSecret}' + } + `; } const connectionConfigStr = - !connectionConfigParamsStr && !authorizationParamsStr && !userScopesStr && !hmacKeyStr && !apiAuthString && !appStoreAuthString + !connectionConfigParamsStr && + !authorizationParamsStr && + !userScopesStr && + !hmacKeyStr && + !apiAuthString && + !appStoreAuthString && + !oauthCredentialsString ? '' : ', { ' + - [connectionConfigParamsStr, authorizationParamsStr, hmacKeyStr, userScopesStr, apiAuthString, appStoreAuthString].filter(Boolean).join(', ') + - ' }'; + [connectionConfigParamsStr, authorizationParamsStr, hmacKeyStr, userScopesStr, apiAuthString, appStoreAuthString, oauthCredentialsString] + .filter(Boolean) + .join(', ') + + '}'; return `import Nango from '@nangohq/frontend'; const nango = new Nango(${argsStr}); -nango.${integration?.authMode === AuthModes.None ? 'create' : 'auth'}('${integration?.uniqueKey}', '${connectionId}'${connectionConfigStr}).then((result: { providerConfigKey: string; connectionId: string }) => { +nango.${integration?.authMode === AuthModes.None ? 'create' : 'auth'}('${integration?.uniqueKey}', '${connectionId}'${connectionConfigStr}) + .then((result: { providerConfigKey: string; connectionId: string }) => { // do something -}).catch((err: { message: string; type: string }) => { + }).catch((err: { message: string; type: string }) => { // handle error -});`; + });`; }; return ( @@ -395,6 +454,54 @@ nango.${integration?.authMode === AuthModes.None ? 'create' : 'auth'}('${integra
)} + {integration?.provider === 'netsuite' && ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ null} + selectedScopes={oauthSelectedScopes} + addToScopesSet={oauthAddToScopesSet} + removeFromSelectedSet={oauthRemoveFromSelectedSet} + minLength={1} + /> +
+
+ )} + {integration?.connectionConfigParams?.map((paramName: string) => (
diff --git a/packages/webapp/src/types.ts b/packages/webapp/src/types.ts index 6043496c6f..7bc0a7d1cc 100644 --- a/packages/webapp/src/types.ts +++ b/packages/webapp/src/types.ts @@ -134,20 +134,26 @@ export interface Connection { oauthToken: string | null; oauthTokenSecret: string | null; rawCredentials: object; - credentials: BasicApiCredentials | ApiKeyCredentials | null; + credentials: BasicApiCredentials | ApiKeyCredentials | OAuthOverride | null; } export interface BasicApiCredentials { - [key: string]: string; username: string; password: string; } export interface ApiKeyCredentials { - [key: string]: string; apiKey: string; } +export interface OAuthOverride { + config_override: { + client_id: string; + client_secret: string; + scopes?: string; + }; +} + export interface User { id: number; email: string;