Files
n8n-enterprise-unlocked/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts

204 lines
7.0 KiB
TypeScript

import type { ClientOAuth2Options, OAuth2CredentialData } from '@n8n/client-oauth2';
import { ClientOAuth2 } from '@n8n/client-oauth2';
import { Response } from 'express';
import pkceChallenge from 'pkce-challenge';
import * as qs from 'querystring';
import omit from 'lodash/omit';
import set from 'lodash/set';
import split from 'lodash/split';
import { Get, RestController } from '@/decorators';
import { jsonStringify } from 'n8n-workflow';
import { OAuthRequest } from '@/requests';
import { AbstractOAuthController, type CsrfStateParam } from './abstract-oauth.controller';
import { GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE as GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE } from '../../constants';
@RestController('/oauth2-credential')
export class OAuth2CredentialController extends AbstractOAuthController {
override oauthVersion = 2;
/** Get Authorization url */
@Get('/auth')
async getAuthUri(req: OAuthRequest.OAuth2Credential.Auth): Promise<string> {
const credential = await this.getCredential(req);
const additionalData = await this.getAdditionalData();
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData);
// 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
if (
decryptedDataOriginal?.scope &&
credential.type.includes('OAuth2') &&
!GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE.includes(credential.type)
) {
delete decryptedDataOriginal.scope;
}
const oauthCredentials = this.applyDefaultsAndOverwrites<OAuth2CredentialData>(
credential,
decryptedDataOriginal,
additionalData,
);
// Generate a CSRF prevention token and send it as an OAuth2 state string
const [csrfSecret, state] = this.createCsrfState(credential.id);
const oAuthOptions = {
...this.convertCredentialToOptions(oauthCredentials),
state,
};
if (oauthCredentials.authQueryParameters) {
oAuthOptions.query = qs.parse(oauthCredentials.authQueryParameters);
}
await this.externalHooks.run('oauth2.authenticate', [oAuthOptions]);
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;
}
await this.encryptAndSaveData(credential, decryptedDataOriginal);
const oAuthObj = new ClientOAuth2(oAuthOptions);
const returnUri = oAuthObj.code.getUri();
this.logger.verbose('OAuth2 authorization url created for credential', {
userId: req.user.id,
credentialId: credential.id,
});
return returnUri.toString();
}
/** Verify and store app code. Generate access tokens and store for respective credential */
@Get('/callback', { usesTemplates: true, skipAuth: true })
async handleCallback(req: OAuthRequest.OAuth2Credential.Callback, res: Response) {
try {
const { code, state: encodedState } = req.query;
if (!code || !encodedState) {
return this.renderCallbackError(
res,
'Insufficient parameters for OAuth2 callback.',
`Received following query parameters: ${JSON.stringify(req.query)}`,
);
}
let state: CsrfStateParam;
try {
state = this.decodeCsrfState(encodedState);
} catch (error) {
return this.renderCallbackError(res, (error as Error).message);
}
const credentialId = state.cid;
const credential = await this.getCredentialWithoutUser(credentialId);
if (!credential) {
const errorMessage = 'OAuth2 callback failed because of insufficient permissions';
this.logger.error(errorMessage, { credentialId });
return this.renderCallbackError(res, errorMessage);
}
const additionalData = await this.getAdditionalData();
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData);
const oauthCredentials = this.applyDefaultsAndOverwrites<OAuth2CredentialData>(
credential,
decryptedDataOriginal,
additionalData,
);
if (this.verifyCsrfState(decryptedDataOriginal, state)) {
const errorMessage = 'The OAuth2 callback state is invalid!';
this.logger.debug(errorMessage, { credentialId });
return this.renderCallbackError(res, errorMessage);
}
let options: Partial<ClientOAuth2Options> = {};
const oAuthOptions = this.convertCredentialToOptions(oauthCredentials);
if (oauthCredentials.grantType === 'pkce') {
options = {
body: { code_verifier: decryptedDataOriginal.codeVerifier },
};
} else if (oauthCredentials.authentication === 'body') {
options = {
body: {
client_id: oAuthOptions.clientId,
client_secret: oAuthOptions.clientSecret,
},
};
delete oAuthOptions.clientSecret;
}
await this.externalHooks.run('oauth2.callback', [oAuthOptions]);
const oAuthObj = new ClientOAuth2(oAuthOptions);
const queryParameters = req.originalUrl.split('?').splice(1, 1).join('');
const oauthToken = await oAuthObj.code.getToken(
`${oAuthOptions.redirectUri as string}?${queryParameters}`,
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!';
this.logger.error(errorMessage, { credentialId });
return this.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;
}
delete decryptedDataOriginal.csrfSecret;
await this.encryptAndSaveData(credential, decryptedDataOriginal);
this.logger.verbose('OAuth2 callback successful for credential', {
credentialId,
});
return res.render('oauth-callback');
} catch (error) {
return this.renderCallbackError(
res,
(error as Error).message,
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
'body' in error ? jsonStringify(error.body) : undefined,
);
}
}
private convertCredentialToOptions(credential: OAuth2CredentialData): ClientOAuth2Options {
return {
clientId: credential.clientId,
clientSecret: credential.clientSecret ?? '',
accessTokenUri: credential.accessTokenUrl ?? '',
authorizationUri: credential.authUrl ?? '',
authentication: credential.authentication ?? 'header',
redirectUri: `${this.baseUrl}/callback`,
scopes: split(credential.scope ?? 'openid', ','),
scopesSeparator: credential.scope?.includes(',') ? ',' : ' ',
ignoreSSLIssues: credential.ignoreSSLIssues ?? false,
};
}
}