mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import type { ClientOAuth2Options } from '@n8n/client-oauth2';
|
|
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
|
import Csrf from 'csrf';
|
|
import express from 'express';
|
|
import pkceChallenge from 'pkce-challenge';
|
|
import * as qs from 'querystring';
|
|
import get from 'lodash/get';
|
|
import omit from 'lodash/omit';
|
|
import set from 'lodash/set';
|
|
import split from 'lodash/split';
|
|
import unset from 'lodash/unset';
|
|
import { Credentials } from 'n8n-core';
|
|
import type { WorkflowExecuteMode, INodeCredentialsDetails } from 'n8n-workflow';
|
|
import { jsonStringify } from 'n8n-workflow';
|
|
import { resolve as pathResolve } from 'path';
|
|
|
|
import * as Db from '@/Db';
|
|
import * as ResponseHelper from '@/ResponseHelper';
|
|
import type { ICredentialsDb } from '@/Interfaces';
|
|
import { RESPONSE_ERROR_MESSAGES, TEMPLATES_DIR } from '@/constants';
|
|
import {
|
|
CredentialsHelper,
|
|
getCredentialForUser,
|
|
getCredentialWithoutUser,
|
|
} from '@/CredentialsHelper';
|
|
import type { OAuthRequest } from '@/requests';
|
|
import { ExternalHooks } from '@/ExternalHooks';
|
|
import config from '@/config';
|
|
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
|
|
import { Container } from 'typedi';
|
|
|
|
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
|
|
import { Logger } from '@/Logger';
|
|
|
|
export const oauth2CredentialController = express.Router();
|
|
|
|
const restEndpoint = config.getEnv('endpoints.rest');
|
|
|
|
/**
|
|
* GET /oauth2-credential/auth
|
|
*
|
|
* Authorize OAuth Data
|
|
*/
|
|
oauth2CredentialController.get(
|
|
'/auth',
|
|
ResponseHelper.send(async (req: OAuthRequest.OAuth1Credential.Auth): Promise<string> => {
|
|
const { id: credentialId } = req.query;
|
|
|
|
if (!credentialId) {
|
|
throw new ResponseHelper.BadRequestError('Required credential ID is missing');
|
|
}
|
|
|
|
const credential = await getCredentialForUser(credentialId, req.user);
|
|
|
|
if (!credential) {
|
|
Container.get(Logger).error('Failed to authorize OAuth2 due to lack of permissions', {
|
|
userId: req.user.id,
|
|
credentialId,
|
|
});
|
|
throw new ResponseHelper.NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL);
|
|
}
|
|
|
|
const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id);
|
|
|
|
const credentialType = credential.type;
|
|
|
|
const mode: WorkflowExecuteMode = 'internal';
|
|
const timezone = config.getEnv('generic.timezone');
|
|
const credentialsHelper = Container.get(CredentialsHelper);
|
|
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
|
|
additionalData,
|
|
credential as INodeCredentialsDetails,
|
|
credentialType,
|
|
mode,
|
|
timezone,
|
|
true,
|
|
);
|
|
|
|
// At some point in the past we saved hidden scopes to credentials (but shouldn't)
|
|
// Delete scope before applying defaults to make sure new scopes are present on reconnect
|
|
// Generic Oauth2 API is an exception because it needs to save the scope
|
|
const genericOAuth2 = ['oAuth2Api', 'googleOAuth2Api', 'microsoftOAuth2Api'];
|
|
if (
|
|
decryptedDataOriginal?.scope &&
|
|
credentialType.includes('OAuth2') &&
|
|
!genericOAuth2.includes(credentialType)
|
|
) {
|
|
delete decryptedDataOriginal.scope;
|
|
}
|
|
|
|
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
|
|
additionalData,
|
|
decryptedDataOriginal,
|
|
credentialType,
|
|
mode,
|
|
timezone,
|
|
);
|
|
|
|
const token = new Csrf();
|
|
// Generate a CSRF prevention token and send it as an OAuth2 state string
|
|
const csrfSecret = token.secretSync();
|
|
const state = {
|
|
token: token.create(csrfSecret),
|
|
cid: req.query.id,
|
|
};
|
|
const stateEncodedStr = Buffer.from(JSON.stringify(state)).toString('base64');
|
|
|
|
const scopes = get(oauthCredentials, 'scope', 'openid') as string;
|
|
const oAuthOptions: ClientOAuth2Options = {
|
|
clientId: get(oauthCredentials, 'clientId') as string,
|
|
clientSecret: get(oauthCredentials, 'clientSecret', '') as string,
|
|
accessTokenUri: get(oauthCredentials, 'accessTokenUrl', '') as string,
|
|
authorizationUri: get(oauthCredentials, 'authUrl', '') as string,
|
|
redirectUri: `${getInstanceBaseUrl()}/${restEndpoint}/oauth2-credential/callback`,
|
|
scopes: split(scopes, ','),
|
|
scopesSeparator: scopes.includes(',') ? ',' : ' ',
|
|
state: stateEncodedStr,
|
|
};
|
|
const authQueryParameters = get(oauthCredentials, 'authQueryParameters', '') as string;
|
|
if (authQueryParameters) {
|
|
oAuthOptions.query = qs.parse(authQueryParameters);
|
|
}
|
|
|
|
await Container.get(ExternalHooks).run('oauth2.authenticate', [oAuthOptions]);
|
|
|
|
const oAuthObj = new ClientOAuth2(oAuthOptions);
|
|
|
|
// Encrypt the data
|
|
const credentials = new Credentials(
|
|
credential as INodeCredentialsDetails,
|
|
credentialType,
|
|
credential.nodesAccess,
|
|
);
|
|
decryptedDataOriginal.csrfSecret = csrfSecret;
|
|
|
|
if (oauthCredentials.grantType === 'pkce') {
|
|
const { code_verifier, code_challenge } = pkceChallenge();
|
|
oAuthOptions.query = {
|
|
...oAuthOptions.query,
|
|
code_challenge,
|
|
code_challenge_method: 'S256',
|
|
};
|
|
decryptedDataOriginal.codeVerifier = code_verifier;
|
|
}
|
|
|
|
credentials.setData(decryptedDataOriginal);
|
|
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
|
|
|
|
// Add special database related data
|
|
newCredentialsData.updatedAt = new Date();
|
|
|
|
// Update the credentials in DB
|
|
await Db.collections.Credentials.update(req.query.id, newCredentialsData);
|
|
|
|
Container.get(Logger).verbose('OAuth2 authorization url created for credential', {
|
|
userId: req.user.id,
|
|
credentialId,
|
|
});
|
|
|
|
return oAuthObj.code.getUri();
|
|
}),
|
|
);
|
|
|
|
const renderCallbackError = (res: express.Response, message: string, reason?: string) =>
|
|
res.render('oauth-error-callback', { error: { message, reason } });
|
|
|
|
/**
|
|
* GET /oauth2-credential/callback
|
|
*
|
|
* Verify and store app code. Generate access tokens and store for respective credential.
|
|
*/
|
|
|
|
oauth2CredentialController.get(
|
|
'/callback',
|
|
async (req: OAuthRequest.OAuth2Credential.Callback, res: express.Response) => {
|
|
try {
|
|
// realmId it's currently just use for the quickbook OAuth2 flow
|
|
const { code, state: stateEncoded } = req.query;
|
|
if (!code || !stateEncoded) {
|
|
return renderCallbackError(
|
|
res,
|
|
'Insufficient parameters for OAuth2 callback.',
|
|
`Received following query parameters: ${JSON.stringify(req.query)}`,
|
|
);
|
|
}
|
|
|
|
let state;
|
|
try {
|
|
state = JSON.parse(Buffer.from(stateEncoded, 'base64').toString()) as {
|
|
cid: string;
|
|
token: string;
|
|
};
|
|
} catch (error) {
|
|
return renderCallbackError(res, 'Invalid state format returned');
|
|
}
|
|
|
|
const credential = await getCredentialWithoutUser(state.cid);
|
|
|
|
if (!credential) {
|
|
const errorMessage = 'OAuth2 callback failed because of insufficient permissions';
|
|
Container.get(Logger).error(errorMessage, {
|
|
userId: req.user?.id,
|
|
credentialId: state.cid,
|
|
});
|
|
return renderCallbackError(res, errorMessage);
|
|
}
|
|
|
|
const additionalData = await WorkflowExecuteAdditionalData.getBase(state.cid);
|
|
|
|
const mode: WorkflowExecuteMode = 'internal';
|
|
const timezone = config.getEnv('generic.timezone');
|
|
const credentialsHelper = Container.get(CredentialsHelper);
|
|
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
|
|
additionalData,
|
|
credential as INodeCredentialsDetails,
|
|
credential.type,
|
|
mode,
|
|
timezone,
|
|
true,
|
|
);
|
|
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
|
|
additionalData,
|
|
decryptedDataOriginal,
|
|
credential.type,
|
|
mode,
|
|
timezone,
|
|
);
|
|
|
|
const token = new Csrf();
|
|
if (
|
|
decryptedDataOriginal.csrfSecret === undefined ||
|
|
!token.verify(decryptedDataOriginal.csrfSecret as string, state.token)
|
|
) {
|
|
const errorMessage = 'The OAuth2 callback state is invalid!';
|
|
Container.get(Logger).debug(errorMessage, {
|
|
userId: req.user?.id,
|
|
credentialId: state.cid,
|
|
});
|
|
return renderCallbackError(res, errorMessage);
|
|
}
|
|
|
|
let options: Partial<ClientOAuth2Options> = {};
|
|
|
|
const scopes = get(oauthCredentials, 'scope', 'openid') as string;
|
|
const oAuth2Parameters: ClientOAuth2Options = {
|
|
clientId: get(oauthCredentials, 'clientId') as string,
|
|
clientSecret: get(oauthCredentials, 'clientSecret', '') as string,
|
|
accessTokenUri: get(oauthCredentials, 'accessTokenUrl', '') as string,
|
|
authorizationUri: get(oauthCredentials, 'authUrl', '') as string,
|
|
redirectUri: `${getInstanceBaseUrl()}/${restEndpoint}/oauth2-credential/callback`,
|
|
scopes: split(scopes, ','),
|
|
scopesSeparator: scopes.includes(',') ? ',' : ' ',
|
|
ignoreSSLIssues: get(oauthCredentials, 'ignoreSSLIssues') as boolean,
|
|
};
|
|
|
|
if (oauthCredentials.grantType === 'pkce') {
|
|
options = {
|
|
body: { code_verifier: decryptedDataOriginal.codeVerifier },
|
|
};
|
|
} else if ((get(oauthCredentials, 'authentication', 'header') as string) === 'body') {
|
|
options = {
|
|
body: {
|
|
client_id: get(oauthCredentials, 'clientId') as string,
|
|
client_secret: get(oauthCredentials, 'clientSecret', '') as string,
|
|
},
|
|
};
|
|
// @ts-ignore
|
|
delete oAuth2Parameters.clientSecret;
|
|
}
|
|
|
|
await Container.get(ExternalHooks).run('oauth2.callback', [oAuth2Parameters]);
|
|
|
|
const oAuthObj = new ClientOAuth2(oAuth2Parameters);
|
|
|
|
const queryParameters = req.originalUrl.split('?').splice(1, 1).join('');
|
|
|
|
const oauthToken = await oAuthObj.code.getToken(
|
|
`${oAuth2Parameters.redirectUri as string}?${queryParameters}`,
|
|
// @ts-ignore
|
|
options,
|
|
);
|
|
|
|
if (Object.keys(req.query).length > 2) {
|
|
set(oauthToken.data, 'callbackQueryString', omit(req.query, 'state', 'code'));
|
|
}
|
|
|
|
if (oauthToken === undefined) {
|
|
const errorMessage = 'Unable to get OAuth2 access tokens!';
|
|
Container.get(Logger).error(errorMessage, {
|
|
userId: req.user?.id,
|
|
credentialId: state.cid,
|
|
});
|
|
return renderCallbackError(res, errorMessage);
|
|
}
|
|
|
|
if (decryptedDataOriginal.oauthTokenData) {
|
|
// Only overwrite supplied data as some providers do for example just return the
|
|
// refresh_token on the very first request and not on subsequent ones.
|
|
Object.assign(decryptedDataOriginal.oauthTokenData, oauthToken.data);
|
|
} else {
|
|
// No data exists so simply set
|
|
decryptedDataOriginal.oauthTokenData = oauthToken.data;
|
|
}
|
|
|
|
unset(decryptedDataOriginal, 'csrfSecret');
|
|
|
|
const credentials = new Credentials(
|
|
credential as INodeCredentialsDetails,
|
|
credential.type,
|
|
credential.nodesAccess,
|
|
);
|
|
credentials.setData(decryptedDataOriginal);
|
|
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
|
|
// Add special database related data
|
|
newCredentialsData.updatedAt = new Date();
|
|
// Save the credentials in DB
|
|
await Db.collections.Credentials.update(state.cid, newCredentialsData);
|
|
Container.get(Logger).verbose('OAuth2 callback successful for new credential', {
|
|
userId: req.user?.id,
|
|
credentialId: state.cid,
|
|
});
|
|
|
|
return res.sendFile(pathResolve(TEMPLATES_DIR, 'oauth-callback.html'));
|
|
} catch (error) {
|
|
return renderCallbackError(
|
|
res,
|
|
(error as Error).message,
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
'body' in error ? jsonStringify(error.body) : undefined,
|
|
);
|
|
}
|
|
},
|
|
);
|