fix(core): Use correct scopes-separator when generating authorization urls (#6502)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2023-06-21 15:19:38 +02:00
committed by GitHub
parent 6ab350209d
commit 5bf83f8bf6
8 changed files with 53 additions and 57 deletions

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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');
} }
/** /**

View File

@@ -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(

View File

@@ -1,2 +1 @@
export type Headers = Record<string, string | string[]>; export type Headers = Record<string, string | string[]>;
export type Query = Record<string, string | string[]>;

View File

@@ -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.
*/ */

View File

@@ -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',
); );
}); });
}); });

View File

@@ -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') {