import type { ClientOAuth2Options, OAuth2CredentialData } from '@n8n/client-oauth2'; import { ClientOAuth2 } from '@n8n/client-oauth2'; import { Response } from 'express'; import omit from 'lodash/omit'; import set from 'lodash/set'; import split from 'lodash/split'; import { jsonStringify } from 'n8n-workflow'; import pkceChallenge from 'pkce-challenge'; import * as qs from 'querystring'; import { GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE as GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE } from '@/constants'; import { Get, RestController } from '@/decorators'; import { OAuthRequest } from '@/requests'; import { AbstractOAuthController, skipAuthOnOAuthCallback } from './abstract-oauth.controller'; @RestController('/oauth2-credential') export class OAuth2CredentialController extends AbstractOAuthController { override oauthVersion = 2; /** Get Authorization url */ @Get('/auth') async getAuthUri(req: OAuthRequest.OAuth2Credential.Auth): Promise { const credential = await this.getCredential(req); const additionalData = await this.getAdditionalData(); const decryptedDataOriginal = await this.getDecryptedDataForAuthUri(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( credential, decryptedDataOriginal, additionalData, ); // Generate a CSRF prevention token and send it as an OAuth2 state string const [csrfSecret, state] = this.createCsrfState( credential.id, skipAuthOnOAuthCallback ? undefined : req.user.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.debug('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: skipAuthOnOAuthCallback }) 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)}`, ); } const [credential, decryptedDataOriginal, oauthCredentials] = await this.resolveCredential(req); let options: Partial = {}; 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 (typeof decryptedDataOriginal.oauthTokenData === 'object') { // 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.debug('OAuth2 callback successful for credential', { credentialId: credential.id, }); 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, }; } }