Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[nan-638] add in credentials override and scopes override #1906

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 21 additions & 3 deletions packages/frontend/lib/index.ts
Expand Up @@ -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<AuthResult> {
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;

Expand Down Expand Up @@ -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}`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here what about making it clearer with override_oauth_client_id and override_oauth_client_secret keys?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think this is necessary here if the interface is OAuthCredentialsOverride as this is just what we push to our server so it is just an internal name

}
}

for (const param in connectionConfig.authorization_params) {
const val = connectionConfig.authorization_params[param];
if (typeof val === 'string') {
Expand All @@ -330,7 +343,12 @@ interface ConnectionConfig {
hmac?: string;
user_scope?: string[];
authorization_params?: Record<string, string | undefined>;
credentials?: BasicApiCredentials | ApiKeyCredentials | AppStoreCredentials;
credentials?: OAuthCredentialsOverride | BasicApiCredentials | ApiKeyCredentials | AppStoreCredentials;
}

interface OAuthCredentialsOverride {
oauth_client_id: string;
oauth_client_secret: string;
}

interface BasicApiCredentials {
Expand Down
20 changes: 20 additions & 0 deletions packages/server/lib/controllers/connection.controller.ts
Expand Up @@ -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']) {
bodinsamuel marked this conversation as resolved.
Show resolved Hide resolved
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,
Expand Down
69 changes: 66 additions & 3 deletions packages/server/lib/controllers/oauth.controller.ts
Expand Up @@ -16,7 +16,7 @@ import type {
TemplateOAuth2 as ProviderTemplateOAuth2,
OAuthSession,
OAuth1RequestTokenResult,
AuthCredentials,
OAuth2Credentials,
ConnectionConfig
} from '@nangohq/shared';
import {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, string> = {};
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, string>, key: string) => {
if (key !== 'oauth_client_id' && key !== 'oauth_client_secret') {
acc[key] = connectionConfig[key] as string;
}
return acc;
}, {});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe it's just me but I have hard time reading reducer, I don't really understand what's going on

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't mind reduce personally but it would be way more readable in typescript if the initial value was the first parameter instead of the last one after the function. Sometimes I have a hard time understanding language creators/maintainers.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have rather just done

delete connectionConfig.oauth_client_id;
delete connectionConfig.oauth_client_secret;

but eslint was yelling at me.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yes I see, I think it's fair to disable some eslint errors when we don't find them relevant.
And if it's often the case, it's always good to revisit the list of activated rules 👌🏻

}

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<string, string>, key: string) => {
if (key !== 'oauth_scopes') {
acc[key] = connectionConfig[key] as string;
}
return acc;
}, {});
}

const [updatedConnection] = await connectionService.upsertConnection(
connectionId,
providerConfigKey,
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/lib/models/Auth.ts
Expand Up @@ -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 {
Expand Down
50 changes: 28 additions & 22 deletions packages/webapp/src/components/ui/input/TagsInput.tsx
Expand Up @@ -32,6 +32,8 @@ const TagsInput = forwardRef<HTMLInputElement, TagsInputProps>(function TagsInpu

const [scopes, setScopes] = useState(selectedScopes);

const readOnly = props.readOnly || false;

useEffect(() => {
const selectedScopesStr = JSON.stringify(selectedScopes);
const optionalSelectedScopesStr = JSON.stringify(optionalSelectedScopes);
Expand Down Expand Up @@ -88,37 +90,41 @@ const TagsInput = forwardRef<HTMLInputElement, TagsInputProps>(function TagsInpu

return (
<>
<div className="flex gap-3">
<input onInvalid={showInvalid} value={scopes.join(',')} {...props} hidden />
<input
ref={ref}
value={enteredValue}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-red-600 text-sm mt-3">{error}</p>}
{enteredValue !== '' && (
<div
className="flex items-center border border-border-gray bg-active-gray text-white rounded-md px-3 py-0.5 mt-0.5 cursor-pointer"
onClick={handleAdd}
>
<PlusSmallIcon className="h-5 w-5" onClick={handleAdd} />
<span className="">Add new scope: &quot;{enteredValue}&quot;</span>
</div>
{!readOnly && (
<>
<div className="flex gap-3">
<input onInvalid={showInvalid} value={scopes.join(',')} {...props} hidden />
<input
ref={ref}
value={enteredValue}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-red-600 text-sm mt-3">{error}</p>}
{enteredValue !== '' && (
<div
className="flex items-center border border-border-gray bg-active-gray text-white rounded-md px-3 py-0.5 mt-0.5 cursor-pointer"
onClick={handleAdd}
>
<PlusSmallIcon className="h-5 w-5" onClick={handleAdd} />
<span className="">Add new scope: &quot;{enteredValue}&quot;</span>
</div>
)}
</>
)}
{Boolean(scopes.length) && (
<div className="pt-1 mb-3 flex flex-wrap space-x-2">
{scopes.map((selectedScope, i) => {
return (
<span
key={selectedScope + i}
className="flex flex-wrap gap-1 pl-4 pr-2 py-1 mt-0.5 justify-between items-center text-sm font-medium rounded-lg cursor-pointer bg-green-600 bg-opacity-20 text-green-600"
className={`${!readOnly ? 'cursor-pointer ' : ''}flex flex-wrap gap-1 pl-4 pr-2 py-1 mt-0.5 justify-between items-center text-sm font-medium rounded-lg bg-green-600 bg-opacity-20 text-green-600`}
>
{selectedScope}
<X onClick={() => removeScope(selectedScope)} className="h-5 w-5" />
{!readOnly && <X onClick={() => removeScope(selectedScope)} className="h-5 w-5" />}
</span>
);
})}
Expand Down
37 changes: 34 additions & 3 deletions packages/webapp/src/pages/Connection/Authorization.tsx
Expand Up @@ -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;
Expand Down Expand Up @@ -47,7 +49,7 @@ export default function Authorization(props: AuthorizationProps) {
<span className="text-gray-400 text-xs uppercase mb-2">Auth Type</span>
<span className="text-white">{connection?.oauthType}</span>
</div>
{connection?.oauthType === AuthModes.ApiKey && (
{connection?.credentials && connection?.oauthType === AuthModes.ApiKey && 'apiKey' in connection.credentials && (
<div className="flex flex-col w-1/2">
<span className="text-gray-400 text-xs uppercase mb-1">{connection?.oauthType}</span>
<SecretInput disabled defaultValue={connection?.credentials?.apiKey} copy={true} />
Expand All @@ -60,7 +62,7 @@ export default function Authorization(props: AuthorizationProps) {
</div>
)}
</div>
{connection?.credentials && connection?.oauthType === AuthModes.Basic && (
{connection?.credentials && connection?.oauthType === AuthModes.Basic && 'password' in connection.credentials && (
<div className="flex">
{connection?.credentials.username && (
<div className="flex flex-col w-1/2">
Expand All @@ -76,6 +78,35 @@ export default function Authorization(props: AuthorizationProps) {
)}
</div>
)}
{connection?.credentials && 'config_override' in connection.credentials && (
<>
{connection?.credentials.config_override.client_id && (
<div className="flex flex-col">
<span className="text-gray-400 text-xs uppercase mb-1">Client ID</span>
<SecretInput disabled value={connection.credentials.config_override.client_id} copy={true} />
</div>
)}
{connection?.credentials.config_override.client_secret && (
<div className="flex flex-col">
<span className="text-gray-400 text-xs uppercase mb-1">Client Secret</span>
<SecretInput disabled value={connection.credentials.config_override.client_secret} copy={true} />
</div>
)}
{connection.credentials.config_override.scopes && (
<div className="mt-8">
<span className="text-gray-400 text-xs uppercase mb-1">Scopes</span>
<TagsInput
id="scopes"
name="scopes"
readOnly
type="text"
defaultValue={connection.credentials.config_override.scopes}
minLength={1}
/>
</div>
)}
</>
)}
{connection?.accessToken && (
<div className="flex flex-col">
<span className="text-gray-400 text-xs uppercase mb-1">Access Token</span>
Expand Down