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 => { 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 = {}; 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, ); } }, );