mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
feat(core): Add PKCE for OAuth2 (#6324)
* Remove authorization header when empty
* Import pkce
* Add OAuth2 with new grant type to Twitter
* Add pkce logic auto assign authorization code if pkce not defined
* Add pkce to ui and interfaces
* Fix scopes for Oauth2 twitter
* Deubg + pass it through header
* Add debug console, add airtable cred
* Remove all console.logs, make PKCE in th body only when it exists
* Remove invalid character ~
* Remove more console.logs
* remove body inside query
* Remove useless grantype check
* Hide oauth2 twitter waiting for overhaul
* Remove redundant header removal
* Remove more console.logs
* Add comment for code verifier
* Remove uneeded scopes
* Restore client id in callback
* Revert "Add OAuth2 with new grant type to Twitter"
This reverts commit 1c3b331aa1
.
* Remove oauth2 from twitter
* Remove properties linked to oauth2
* Fix lodash imports
* remove redundant check
* remove redundant codeVerifier
* patch pkce-challenge to avoid generating `code_verifier` with `~`
* store `codeVerifier` on the DB like `csrfSecret`
* remove unrelated changes
---------
Co-authored-by: Marcus <marcus@n8n.io>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
parent
4b0e0b7970
commit
fc7261aca6
|
@ -94,7 +94,8 @@
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"element-ui@2.15.12": "patches/element-ui@2.15.12.patch",
|
"element-ui@2.15.12": "patches/element-ui@2.15.12.patch",
|
||||||
"typedi@0.10.0": "patches/typedi@0.10.0.patch",
|
"typedi@0.10.0": "patches/typedi@0.10.0.patch",
|
||||||
"@sentry/cli@2.17.0": "patches/@sentry__cli@2.17.0.patch"
|
"@sentry/cli@2.17.0": "patches/@sentry__cli@2.17.0.patch",
|
||||||
|
"pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -163,6 +163,7 @@
|
||||||
"passport-cookie": "^1.0.9",
|
"passport-cookie": "^1.0.9",
|
||||||
"passport-jwt": "^4.0.0",
|
"passport-jwt": "^4.0.0",
|
||||||
"pg": "^8.8.0",
|
"pg": "^8.8.0",
|
||||||
|
"pkce-challenge": "^3.0.0",
|
||||||
"picocolors": "^1.0.0",
|
"picocolors": "^1.0.0",
|
||||||
"posthog-node": "^2.2.2",
|
"posthog-node": "^2.2.2",
|
||||||
"prom-client": "^13.1.0",
|
"prom-client": "^13.1.0",
|
||||||
|
|
|
@ -2,6 +2,7 @@ import type { ClientOAuth2Options } from '@n8n/client-oauth2';
|
||||||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||||
import Csrf from 'csrf';
|
import Csrf from 'csrf';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import pkceChallenge from 'pkce-challenge';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import omit from 'lodash/omit';
|
import omit from 'lodash/omit';
|
||||||
import set from 'lodash/set';
|
import set from 'lodash/set';
|
||||||
|
@ -142,6 +143,16 @@ oauth2CredentialController.get(
|
||||||
);
|
);
|
||||||
decryptedDataOriginal.csrfSecret = csrfSecret;
|
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, encryptionKey);
|
credentials.setData(decryptedDataOriginal, encryptionKey);
|
||||||
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
|
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
|
||||||
|
|
||||||
|
@ -189,7 +200,6 @@ oauth2CredentialController.get(
|
||||||
try {
|
try {
|
||||||
// realmId it's currently just use for the quickbook OAuth2 flow
|
// realmId it's currently just use for the quickbook OAuth2 flow
|
||||||
const { code, state: stateEncoded } = req.query;
|
const { code, state: stateEncoded } = req.query;
|
||||||
|
|
||||||
if (!code || !stateEncoded) {
|
if (!code || !stateEncoded) {
|
||||||
return renderCallbackError(
|
return renderCallbackError(
|
||||||
res,
|
res,
|
||||||
|
@ -265,12 +275,21 @@ oauth2CredentialController.get(
|
||||||
if ((get(oauthCredentials, 'authentication', 'header') as string) === 'body') {
|
if ((get(oauthCredentials, 'authentication', 'header') as string) === 'body') {
|
||||||
options = {
|
options = {
|
||||||
body: {
|
body: {
|
||||||
client_id: get(oauthCredentials, 'clientId') as string,
|
...(oauthCredentials.grantType === 'pkce' && {
|
||||||
client_secret: get(oauthCredentials, 'clientSecret', '') as string,
|
code_verifier: decryptedDataOriginal.codeVerifier,
|
||||||
|
}),
|
||||||
|
...(oauthCredentials.grantType === 'authorizationCode' && {
|
||||||
|
client_id: get(oauthCredentials, 'clientId') as string,
|
||||||
|
client_secret: get(oauthCredentials, 'clientSecret', '') as string,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete oAuth2Parameters.clientSecret;
|
delete oAuth2Parameters.clientSecret;
|
||||||
|
} else if (oauthCredentials.grantType === 'pkce') {
|
||||||
|
options = {
|
||||||
|
body: { code_verifier: decryptedDataOriginal.codeVerifier },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await Container.get(ExternalHooks).run('oauth2.callback', [oAuth2Parameters]);
|
await Container.get(ExternalHooks).run('oauth2.callback', [oAuth2Parameters]);
|
||||||
|
|
|
@ -1104,14 +1104,12 @@ export async function requestOAuth2(
|
||||||
});
|
});
|
||||||
|
|
||||||
let oauthTokenData = credentials.oauthTokenData as ClientOAuth2TokenData;
|
let oauthTokenData = credentials.oauthTokenData as ClientOAuth2TokenData;
|
||||||
|
|
||||||
// if it's the first time using the credentials, get the access token and save it into the DB.
|
// if it's the first time using the credentials, get the access token and save it into the DB.
|
||||||
if (
|
if (
|
||||||
credentials.grantType === OAuth2GrantType.clientCredentials &&
|
credentials.grantType === OAuth2GrantType.clientCredentials &&
|
||||||
(oauthTokenData === undefined || Object.keys(oauthTokenData).length === 0)
|
(oauthTokenData === undefined || Object.keys(oauthTokenData).length === 0)
|
||||||
) {
|
) {
|
||||||
const { data } = await getClientCredentialsToken(oAuthClient, credentials);
|
const { data } = await getClientCredentialsToken(oAuthClient, credentials);
|
||||||
|
|
||||||
// Find the credentials
|
// Find the credentials
|
||||||
if (!node.credentials?.[credentialsType]) {
|
if (!node.credentials?.[credentialsType]) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
@ -1150,7 +1148,6 @@ export async function requestOAuth2(
|
||||||
if (oAuth2Options?.keepBearer === false && typeof newRequestHeaders.Authorization === 'string') {
|
if (oAuth2Options?.keepBearer === false && typeof newRequestHeaders.Authorization === 'string') {
|
||||||
newRequestHeaders.Authorization = newRequestHeaders.Authorization.split(' ')[1];
|
newRequestHeaders.Authorization = newRequestHeaders.Authorization.split(' ')[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
|
if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
|
||||||
Object.assign(newRequestHeaders, {
|
Object.assign(newRequestHeaders, {
|
||||||
[oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken,
|
[oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken,
|
||||||
|
@ -1166,7 +1163,9 @@ export async function requestOAuth2(
|
||||||
if (oAuth2Options?.includeCredentialsOnRefreshOnBody) {
|
if (oAuth2Options?.includeCredentialsOnRefreshOnBody) {
|
||||||
const body: IDataObject = {
|
const body: IDataObject = {
|
||||||
client_id: credentials.clientId as string,
|
client_id: credentials.clientId as string,
|
||||||
client_secret: credentials.clientSecret as string,
|
...(credentials.grantType === 'authorizationCode' && {
|
||||||
|
client_secret: credentials.clientSecret as string,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
tokenRefreshOptions.body = body;
|
tokenRefreshOptions.body = body;
|
||||||
tokenRefreshOptions.headers = {
|
tokenRefreshOptions.headers = {
|
||||||
|
|
|
@ -412,7 +412,8 @@ export default defineComponent({
|
||||||
return (
|
return (
|
||||||
!!this.credentialTypeName &&
|
!!this.credentialTypeName &&
|
||||||
(((this.credentialTypeName === 'oAuth2Api' || this.parentTypes.includes('oAuth2Api')) &&
|
(((this.credentialTypeName === 'oAuth2Api' || this.parentTypes.includes('oAuth2Api')) &&
|
||||||
this.credentialData.grantType === 'authorizationCode') ||
|
(this.credentialData.grantType === 'authorizationCode' ||
|
||||||
|
this.credentialData.grantType === 'pkce')) ||
|
||||||
this.credentialTypeName === 'oAuth1Api' ||
|
this.credentialTypeName === 'oAuth1Api' ||
|
||||||
this.parentTypes.includes('oAuth1Api'))
|
this.parentTypes.includes('oAuth1Api'))
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
|
const scopes = ['schema.bases:read', 'data.records:read', 'data.records:write'];
|
||||||
|
|
||||||
|
export class AirtableOAuth2Api implements ICredentialType {
|
||||||
|
name = 'airtableOAuth2Api';
|
||||||
|
|
||||||
|
extends = ['oAuth2Api'];
|
||||||
|
|
||||||
|
displayName = 'Airtable OAuth2 API';
|
||||||
|
|
||||||
|
documentationUrl = 'airtable';
|
||||||
|
|
||||||
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Grant Type',
|
||||||
|
name: 'grantType',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'pkce',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Authorization URL',
|
||||||
|
name: 'authUrl',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'https://airtable.com/oauth2/v1/authorize',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Access Token URL',
|
||||||
|
name: 'accessTokenUrl',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'https://airtable.com/oauth2/v1/token',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Scope',
|
||||||
|
name: 'scope',
|
||||||
|
type: 'hidden',
|
||||||
|
default: `${scopes.join(' ')}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Auth URI Query Parameters',
|
||||||
|
name: 'authQueryParameters',
|
||||||
|
type: 'hidden',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Authentication',
|
||||||
|
name: 'authentication',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'header',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
|
@ -23,6 +23,10 @@ export class OAuth2Api implements ICredentialType {
|
||||||
name: 'Client Credentials',
|
name: 'Client Credentials',
|
||||||
value: 'clientCredentials',
|
value: 'clientCredentials',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'PKCE',
|
||||||
|
value: 'pkce',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
default: 'authorizationCode',
|
default: 'authorizationCode',
|
||||||
},
|
},
|
||||||
|
@ -32,7 +36,7 @@ export class OAuth2Api implements ICredentialType {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
show: {
|
show: {
|
||||||
grantType: ['authorizationCode'],
|
grantType: ['authorizationCode', 'pkce'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
default: '',
|
default: '',
|
||||||
|
@ -74,7 +78,7 @@ export class OAuth2Api implements ICredentialType {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
show: {
|
show: {
|
||||||
grantType: ['authorizationCode'],
|
grantType: ['authorizationCode', 'pkce'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
default: '',
|
default: '',
|
||||||
|
|
|
@ -42,6 +42,15 @@ export class Airtable implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'airtableOAuth2Api',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
authentication: ['airtableOAuth2Api'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
properties: [
|
properties: [
|
||||||
{
|
{
|
||||||
|
@ -57,6 +66,10 @@ export class Airtable implements INodeType {
|
||||||
name: 'Access Token',
|
name: 'Access Token',
|
||||||
value: 'airtableTokenApi',
|
value: 'airtableTokenApi',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'OAuth2',
|
||||||
|
value: 'airtableOAuth2Api',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
default: 'airtableApi',
|
default: 'airtableApi',
|
||||||
},
|
},
|
||||||
|
|
|
@ -39,6 +39,7 @@
|
||||||
"dist/credentials/AffinityApi.credentials.js",
|
"dist/credentials/AffinityApi.credentials.js",
|
||||||
"dist/credentials/AgileCrmApi.credentials.js",
|
"dist/credentials/AgileCrmApi.credentials.js",
|
||||||
"dist/credentials/AirtableApi.credentials.js",
|
"dist/credentials/AirtableApi.credentials.js",
|
||||||
|
"dist/credentials/AirtableOAuth2Api.credentials.js",
|
||||||
"dist/credentials/AirtableTokenApi.credentials.js",
|
"dist/credentials/AirtableTokenApi.credentials.js",
|
||||||
"dist/credentials/Amqp.credentials.js",
|
"dist/credentials/Amqp.credentials.js",
|
||||||
"dist/credentials/ApiTemplateIoApi.credentials.js",
|
"dist/credentials/ApiTemplateIoApi.credentials.js",
|
||||||
|
|
|
@ -1907,11 +1907,12 @@ export interface IConnectedNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum OAuth2GrantType {
|
export const enum OAuth2GrantType {
|
||||||
|
pkce = 'pkce',
|
||||||
authorizationCode = 'authorizationCode',
|
authorizationCode = 'authorizationCode',
|
||||||
clientCredentials = 'clientCredentials',
|
clientCredentials = 'clientCredentials',
|
||||||
}
|
}
|
||||||
export interface IOAuth2Credentials {
|
export interface IOAuth2Credentials {
|
||||||
grantType: 'authorizationCode' | 'clientCredentials';
|
grantType: 'authorizationCode' | 'clientCredentials' | 'pkce';
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
accessTokenUrl: string;
|
accessTokenUrl: string;
|
||||||
|
|
13
patches/pkce-challenge@3.0.0.patch
Normal file
13
patches/pkce-challenge@3.0.0.patch
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
diff --git a/dist/main.js b/dist/main.js
|
||||||
|
index 86be84f44210b26583e0a7f1732acd8b98a5e701..a2b05be6a45355704fedf43b51a34793580eaf6c 100644
|
||||||
|
--- a/dist/main.js
|
||||||
|
+++ b/dist/main.js
|
||||||
|
@@ -42,7 +42,7 @@ $parcel$export(module.exports, "verifyChallenge", () => $f5bfd4ce37214f4f$export
|
||||||
|
* @param size The desired length of the string
|
||||||
|
* @returns The random string
|
||||||
|
*/ function $f5bfd4ce37214f4f$var$random(size) {
|
||||||
|
- const mask = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
|
||||||
|
+ const mask = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._";
|
||||||
|
let result = "";
|
||||||
|
const randomUints = $f5bfd4ce37214f4f$var$getRandomValues(size);
|
||||||
|
for(let i = 0; i < size; i++){
|
|
@ -32,6 +32,9 @@ patchedDependencies:
|
||||||
element-ui@2.15.12:
|
element-ui@2.15.12:
|
||||||
hash: prckukfdop5sl2her6de25cod4
|
hash: prckukfdop5sl2her6de25cod4
|
||||||
path: patches/element-ui@2.15.12.patch
|
path: patches/element-ui@2.15.12.patch
|
||||||
|
pkce-challenge@3.0.0:
|
||||||
|
hash: dypouzb3lve7vncq25i5fuanki
|
||||||
|
path: patches/pkce-challenge@3.0.0.patch
|
||||||
typedi@0.10.0:
|
typedi@0.10.0:
|
||||||
hash: 62r6bc2crgimafeyruodhqlgo4
|
hash: 62r6bc2crgimafeyruodhqlgo4
|
||||||
path: patches/typedi@0.10.0.patch
|
path: patches/typedi@0.10.0.patch
|
||||||
|
@ -383,6 +386,9 @@ importers:
|
||||||
picocolors:
|
picocolors:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
pkce-challenge:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0(patch_hash=dypouzb3lve7vncq25i5fuanki)
|
||||||
posthog-node:
|
posthog-node:
|
||||||
specifier: ^2.2.2
|
specifier: ^2.2.2
|
||||||
version: 2.2.2
|
version: 2.2.2
|
||||||
|
@ -18092,6 +18098,13 @@ packages:
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/pkce-challenge@3.0.0(patch_hash=dypouzb3lve7vncq25i5fuanki):
|
||||||
|
resolution: {integrity: sha512-sQ8sJJJuLhA5pFnoxayMCrFnBMNj7DDpa+TWxOXl4B24oXHlVSADi/3Bowm66QuzWkBuF6DhmaelCdlC2JKwsg==}
|
||||||
|
dependencies:
|
||||||
|
crypto-js: 4.1.1
|
||||||
|
dev: false
|
||||||
|
patched: true
|
||||||
|
|
||||||
/pkg-dir@3.0.0:
|
/pkg-dir@3.0.0:
|
||||||
resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==}
|
resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
Loading…
Reference in a new issue