fix(core): Use correct scopes-separator when generating authorization urls (#6502)
This commit is contained in:
committed by
GitHub
parent
6ab350209d
commit
5bf83f8bf6
@@ -10,28 +10,28 @@ import type { ClientOAuth2TokenData } from './ClientOAuth2Token';
|
|||||||
import { ClientOAuth2Token } from './ClientOAuth2Token';
|
import { ClientOAuth2Token } from './ClientOAuth2Token';
|
||||||
import { CodeFlow } from './CodeFlow';
|
import { CodeFlow } from './CodeFlow';
|
||||||
import { CredentialsFlow } from './CredentialsFlow';
|
import { CredentialsFlow } from './CredentialsFlow';
|
||||||
import type { Headers, Query } from './types';
|
import type { Headers } from './types';
|
||||||
|
|
||||||
export interface ClientOAuth2RequestObject {
|
export interface ClientOAuth2RequestObject {
|
||||||
url: string;
|
url: string;
|
||||||
method: 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT';
|
method: 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT';
|
||||||
body?: Record<string, any>;
|
body?: Record<string, any>;
|
||||||
query?: Query;
|
query?: qs.ParsedUrlQuery;
|
||||||
headers?: Headers;
|
headers?: Headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientOAuth2Options {
|
export interface ClientOAuth2Options {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret?: string;
|
||||||
accessTokenUri: string;
|
accessTokenUri: string;
|
||||||
authorizationUri?: string;
|
authorizationUri?: string;
|
||||||
redirectUri?: string;
|
redirectUri?: string;
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
|
scopesSeparator?: ',' | ' ';
|
||||||
authorizationGrants?: string[];
|
authorizationGrants?: string[];
|
||||||
state?: string;
|
state?: string;
|
||||||
body?: Record<string, any>;
|
body?: Record<string, any>;
|
||||||
query?: Query;
|
query?: qs.ParsedUrlQuery;
|
||||||
headers?: Headers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResponseError extends Error {
|
class ResponseError extends Error {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
import type { ClientOAuth2, ClientOAuth2Options, ClientOAuth2RequestObject } from './ClientOAuth2';
|
import type { ClientOAuth2, ClientOAuth2Options, ClientOAuth2RequestObject } from './ClientOAuth2';
|
||||||
import { auth, getRequestOptions } from './utils';
|
import { auth, expects, getRequestOptions } from './utils';
|
||||||
import { DEFAULT_HEADERS } from './constants';
|
import { DEFAULT_HEADERS } from './constants';
|
||||||
|
|
||||||
export interface ClientOAuth2TokenData extends Record<string, string | undefined> {
|
export interface ClientOAuth2TokenData extends Record<string, string | undefined> {
|
||||||
@@ -69,6 +69,8 @@ export class ClientOAuth2Token {
|
|||||||
async refresh(opts?: ClientOAuth2Options): Promise<ClientOAuth2Token> {
|
async refresh(opts?: ClientOAuth2Options): Promise<ClientOAuth2Token> {
|
||||||
const options = { ...this.client.options, ...opts };
|
const options = { ...this.client.options, ...opts };
|
||||||
|
|
||||||
|
expects(options, 'clientSecret');
|
||||||
|
|
||||||
if (!this.refreshToken) throw new Error('No refresh token');
|
if (!this.refreshToken) throw new Error('No refresh token');
|
||||||
|
|
||||||
const requestOptions = getRequestOptions(
|
const requestOptions = getRequestOptions(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as qs from 'querystring';
|
|||||||
import type { ClientOAuth2, ClientOAuth2Options } from './ClientOAuth2';
|
import type { ClientOAuth2, ClientOAuth2Options } from './ClientOAuth2';
|
||||||
import type { ClientOAuth2Token, ClientOAuth2TokenData } from './ClientOAuth2Token';
|
import type { ClientOAuth2Token, ClientOAuth2TokenData } from './ClientOAuth2Token';
|
||||||
import { DEFAULT_HEADERS, DEFAULT_URL_BASE } from './constants';
|
import { DEFAULT_HEADERS, DEFAULT_URL_BASE } from './constants';
|
||||||
import { auth, expects, getAuthError, getRequestOptions, sanitizeScope } from './utils';
|
import { auth, expects, getAuthError, getRequestOptions } from './utils';
|
||||||
|
|
||||||
interface CodeFlowBody {
|
interface CodeFlowBody {
|
||||||
code: string | string[];
|
code: string | string[];
|
||||||
@@ -22,27 +22,30 @@ export class CodeFlow {
|
|||||||
/**
|
/**
|
||||||
* Generate the uri for doing the first redirect.
|
* Generate the uri for doing the first redirect.
|
||||||
*/
|
*/
|
||||||
getUri(opts?: ClientOAuth2Options): string {
|
getUri(opts?: Partial<ClientOAuth2Options>): string {
|
||||||
const options = { ...this.client.options, ...opts };
|
const options: ClientOAuth2Options = { ...this.client.options, ...opts };
|
||||||
|
|
||||||
// Check the required parameters are set.
|
// Check the required parameters are set.
|
||||||
expects(options, 'clientId', 'authorizationUri');
|
expects(options, 'clientId', 'authorizationUri');
|
||||||
|
|
||||||
const query: Record<string, string | undefined> = {
|
const url = new URL(options.authorizationUri);
|
||||||
|
|
||||||
|
const queryParams = {
|
||||||
|
...options.query,
|
||||||
client_id: options.clientId,
|
client_id: options.clientId,
|
||||||
redirect_uri: options.redirectUri,
|
redirect_uri: options.redirectUri,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
state: options.state,
|
state: options.state,
|
||||||
|
...(options.scopes ? { scope: options.scopes.join(options.scopesSeparator ?? ' ') } : {}),
|
||||||
};
|
};
|
||||||
if (options.scopes !== undefined) {
|
|
||||||
query.scope = sanitizeScope(options.scopes);
|
for (const [key, value] of Object.entries(queryParams)) {
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
url.searchParams.append(key, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.authorizationUri) {
|
return url.toString();
|
||||||
const sep = options.authorizationUri.includes('?') ? '&' : '?';
|
|
||||||
return options.authorizationUri + sep + qs.stringify({ ...query, ...options.query });
|
|
||||||
}
|
|
||||||
throw new TypeError('Missing authorization uri, unable to get redirect uri');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ClientOAuth2, ClientOAuth2Options } from './ClientOAuth2';
|
import type { ClientOAuth2, ClientOAuth2Options } from './ClientOAuth2';
|
||||||
import type { ClientOAuth2Token, ClientOAuth2TokenData } from './ClientOAuth2Token';
|
import type { ClientOAuth2Token, ClientOAuth2TokenData } from './ClientOAuth2Token';
|
||||||
import { DEFAULT_HEADERS } from './constants';
|
import { DEFAULT_HEADERS } from './constants';
|
||||||
import { auth, expects, getRequestOptions, sanitizeScope } from './utils';
|
import { auth, expects, getRequestOptions } from './utils';
|
||||||
|
|
||||||
interface CredentialsFlowBody {
|
interface CredentialsFlowBody {
|
||||||
grant_type: 'client_credentials';
|
grant_type: 'client_credentials';
|
||||||
@@ -29,7 +29,7 @@ export class CredentialsFlow {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (options.scopes !== undefined) {
|
if (options.scopes !== undefined) {
|
||||||
body.scope = sanitizeScope(options.scopes);
|
body.scope = options.scopes.join(options.scopesSeparator ?? ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestOptions = getRequestOptions(
|
const requestOptions = getRequestOptions(
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export type Headers = Record<string, string | string[]>;
|
export type Headers = Record<string, string | string[]>;
|
||||||
export type Query = Record<string, string | string[]>;
|
|
||||||
|
|||||||
@@ -2,17 +2,21 @@
|
|||||||
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import type { ClientOAuth2RequestObject } from './ClientOAuth2';
|
import type { ClientOAuth2Options, ClientOAuth2RequestObject } from './ClientOAuth2';
|
||||||
import { ERROR_RESPONSES } from './constants';
|
import { ERROR_RESPONSES } from './constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if properties exist on an object and throw when they aren't.
|
* Check if properties exist on an object and throw when they aren't.
|
||||||
*/
|
*/
|
||||||
export function expects(obj: any, ...args: any[]) {
|
export function expects<Keys extends keyof ClientOAuth2Options>(
|
||||||
for (let i = 1; i < args.length; i++) {
|
obj: ClientOAuth2Options,
|
||||||
const prop = args[i];
|
...keys: Keys[]
|
||||||
if (obj[prop] === null) {
|
): asserts obj is ClientOAuth2Options & {
|
||||||
throw new TypeError('Expected "' + prop + '" to exist');
|
[K in Keys]: NonNullable<ClientOAuth2Options[K]>;
|
||||||
|
} {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (obj[key] === null || obj[key] === undefined) {
|
||||||
|
throw new TypeError('Expected "' + key + '" to exist');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,13 +51,6 @@ function toString(str: string | null | undefined) {
|
|||||||
return str === null ? '' : String(str);
|
return str === null ? '' : String(str);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize the scopes option to be a string.
|
|
||||||
*/
|
|
||||||
export function sanitizeScope(scopes: string[] | string): string {
|
|
||||||
return Array.isArray(scopes) ? scopes.join(' ') : toString(scopes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create basic auth header.
|
* Create basic auth header.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe('CodeFlow', () => {
|
|||||||
expect(githubAuth.code.getUri()).toEqual(
|
expect(githubAuth.code.getUri()).toEqual(
|
||||||
`${config.authorizationUri}?client_id=abc&` +
|
`${config.authorizationUri}?client_id=abc&` +
|
||||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||||
'response_type=code&state=&scope=notifications',
|
'response_type=code&scope=notifications',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ describe('CodeFlow', () => {
|
|||||||
expect(authWithoutScopes.code.getUri()).toEqual(
|
expect(authWithoutScopes.code.getUri()).toEqual(
|
||||||
`${config.authorizationUri}?client_id=abc&` +
|
`${config.authorizationUri}?client_id=abc&` +
|
||||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||||
'response_type=code&state=',
|
'response_type=code',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -64,7 +64,7 @@ describe('CodeFlow', () => {
|
|||||||
expect(authWithEmptyScopes.code.getUri()).toEqual(
|
expect(authWithEmptyScopes.code.getUri()).toEqual(
|
||||||
`${config.authorizationUri}?client_id=abc&` +
|
`${config.authorizationUri}?client_id=abc&` +
|
||||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||||
'response_type=code&state=&scope=',
|
'response_type=code&scope=',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ describe('CodeFlow', () => {
|
|||||||
expect(authWithEmptyScopes.code.getUri()).toEqual(
|
expect(authWithEmptyScopes.code.getUri()).toEqual(
|
||||||
`${config.authorizationUri}?client_id=abc&` +
|
`${config.authorizationUri}?client_id=abc&` +
|
||||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||||
'response_type=code&state=&scope=',
|
'response_type=code&scope=',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ describe('CodeFlow', () => {
|
|||||||
expect(authWithParams.code.getUri()).toEqual(
|
expect(authWithParams.code.getUri()).toEqual(
|
||||||
`${config.authorizationUri}?bar=qux&client_id=abc&` +
|
`${config.authorizationUri}?bar=qux&client_id=abc&` +
|
||||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||||
'response_type=code&state=&scope=notifications',
|
'response_type=code&scope=notifications',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 pkceChallenge from 'pkce-challenge';
|
||||||
|
import * as qs from 'querystring';
|
||||||
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';
|
||||||
@@ -121,15 +122,21 @@ oauth2CredentialController.get(
|
|||||||
};
|
};
|
||||||
const stateEncodedStr = Buffer.from(JSON.stringify(state)).toString('base64');
|
const stateEncodedStr = Buffer.from(JSON.stringify(state)).toString('base64');
|
||||||
|
|
||||||
|
const scopes = get(oauthCredentials, 'scope', 'openid') as string;
|
||||||
const oAuthOptions: ClientOAuth2Options = {
|
const oAuthOptions: ClientOAuth2Options = {
|
||||||
clientId: get(oauthCredentials, 'clientId') as string,
|
clientId: get(oauthCredentials, 'clientId') as string,
|
||||||
clientSecret: get(oauthCredentials, 'clientSecret', '') as string,
|
clientSecret: get(oauthCredentials, 'clientSecret', '') as string,
|
||||||
accessTokenUri: get(oauthCredentials, 'accessTokenUrl', '') as string,
|
accessTokenUri: get(oauthCredentials, 'accessTokenUrl', '') as string,
|
||||||
authorizationUri: get(oauthCredentials, 'authUrl', '') as string,
|
authorizationUri: get(oauthCredentials, 'authUrl', '') as string,
|
||||||
redirectUri: `${getInstanceBaseUrl()}/${restEndpoint}/oauth2-credential/callback`,
|
redirectUri: `${getInstanceBaseUrl()}/${restEndpoint}/oauth2-credential/callback`,
|
||||||
scopes: split(get(oauthCredentials, 'scope', 'openid,') as string, ','),
|
scopes: split(scopes, ','),
|
||||||
|
scopesSeparator: scopes.includes(',') ? ',' : ' ',
|
||||||
state: stateEncodedStr,
|
state: stateEncodedStr,
|
||||||
};
|
};
|
||||||
|
const authQueryParameters = get(oauthCredentials, 'authQueryParameters', '') as string;
|
||||||
|
if (authQueryParameters) {
|
||||||
|
oAuthOptions.query = qs.parse(authQueryParameters);
|
||||||
|
}
|
||||||
|
|
||||||
await Container.get(ExternalHooks).run('oauth2.authenticate', [oAuthOptions]);
|
await Container.get(ExternalHooks).run('oauth2.authenticate', [oAuthOptions]);
|
||||||
|
|
||||||
@@ -162,26 +169,12 @@ oauth2CredentialController.get(
|
|||||||
// Update the credentials in DB
|
// Update the credentials in DB
|
||||||
await Db.collections.Credentials.update(req.query.id, newCredentialsData);
|
await Db.collections.Credentials.update(req.query.id, newCredentialsData);
|
||||||
|
|
||||||
const authQueryParameters = get(oauthCredentials, 'authQueryParameters', '') as string;
|
LoggerProxy.verbose('OAuth2 authorization url created for credential', {
|
||||||
let returnUri = oAuthObj.code.getUri();
|
|
||||||
|
|
||||||
// if scope uses comma, change it as the library always return then with spaces
|
|
||||||
if ((get(oauthCredentials, 'scope') as string).includes(',')) {
|
|
||||||
const data = returnUri.split('?')[1];
|
|
||||||
const scope = get(oauthCredentials, 'scope') as string;
|
|
||||||
const percentEncoded = [data, `scope=${encodeURIComponent(scope)}`].join('&');
|
|
||||||
returnUri = `${get(oauthCredentials, 'authUrl', '') as string}?${percentEncoded}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authQueryParameters) {
|
|
||||||
returnUri += `&${authQueryParameters}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
LoggerProxy.verbose('OAuth2 authentication successful for new credential', {
|
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
credentialId,
|
credentialId,
|
||||||
});
|
});
|
||||||
return returnUri;
|
|
||||||
|
return oAuthObj.code.getUri();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -263,13 +256,15 @@ oauth2CredentialController.get(
|
|||||||
|
|
||||||
let options: Partial<ClientOAuth2Options> = {};
|
let options: Partial<ClientOAuth2Options> = {};
|
||||||
|
|
||||||
|
const scopes = get(oauthCredentials, 'scope', 'openid') as string;
|
||||||
const oAuth2Parameters: ClientOAuth2Options = {
|
const oAuth2Parameters: ClientOAuth2Options = {
|
||||||
clientId: get(oauthCredentials, 'clientId') as string,
|
clientId: get(oauthCredentials, 'clientId') as string,
|
||||||
clientSecret: get(oauthCredentials, 'clientSecret', '') as string,
|
clientSecret: get(oauthCredentials, 'clientSecret', '') as string,
|
||||||
accessTokenUri: get(oauthCredentials, 'accessTokenUrl', '') as string,
|
accessTokenUri: get(oauthCredentials, 'accessTokenUrl', '') as string,
|
||||||
authorizationUri: get(oauthCredentials, 'authUrl', '') as string,
|
authorizationUri: get(oauthCredentials, 'authUrl', '') as string,
|
||||||
redirectUri: `${getInstanceBaseUrl()}/${restEndpoint}/oauth2-credential/callback`,
|
redirectUri: `${getInstanceBaseUrl()}/${restEndpoint}/oauth2-credential/callback`,
|
||||||
scopes: split(get(oauthCredentials, 'scope', 'openid,') as string, ','),
|
scopes: split(scopes, ','),
|
||||||
|
scopesSeparator: scopes.includes(',') ? ',' : ' ',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (oauthCredentials.grantType === 'pkce') {
|
if (oauthCredentials.grantType === 'pkce') {
|
||||||
|
|||||||
Reference in New Issue
Block a user