feat: Support oauth1 authorization for dynamic credentials (#22972)

This commit is contained in:
Stephen Wright
2025-12-09 14:42:11 +00:00
committed by GitHub
parent e1e4c821a4
commit 0a56a56dc5
9 changed files with 297 additions and 201 deletions
@@ -32,24 +32,19 @@ describe('OAuth1CredentialController', () => {
it('should return a valid auth URI', async () => {
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
oauthService.getCredential.mockResolvedValueOnce(mockResolvedCredential);
oauthService.createCsrfState.mockReturnValueOnce(['csrf-secret', 'state']);
oauthService.getOAuthCredentials.mockResolvedValueOnce({
requestTokenUrl: 'https://example.domain/oauth/request_token',
authUrl: 'https://example.domain/oauth/authorize',
accessTokenUrl: 'https://example.domain/oauth/access_token',
signatureMethod: 'HMAC-SHA1' as const,
});
jest.mocked(axios).request.mockResolvedValueOnce({ data: { oauth_token: 'random-token' } });
oauthService.generateAOauth1AuthUri.mockResolvedValueOnce(
'https://example.domain/oauth/authorize?oauth_token=random-token',
);
const req = mock<OAuthRequest.OAuth1Credential.Auth>({
user: mock<User>({ id: '123' }),
query: { id: '1' },
});
const authUri = await controller.getAuthUri(req);
expect(authUri).toEqual('https://example.domain/oauth/authorize?oauth_token=random-token');
expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
mockResolvedCredential,
expect.objectContaining({ csrfSecret: 'csrf-secret' }),
);
expect(oauthService.generateAOauth1AuthUri).toHaveBeenCalledWith(mockResolvedCredential, {
cid: '1',
userId: '123',
});
});
});
@@ -66,17 +66,10 @@ describe('OAuth2CredentialController', () => {
const authUri = await controller.getAuthUri(req);
expect(authUri).toContain('https://example.domain/oauth2/auth');
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(
mockResolvedCredential,
expect.objectContaining({
clientId: 'client_id',
authUrl: 'https://example.domain/oauth2/auth',
}),
{
cid: '1',
userId: '123',
},
);
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(mockResolvedCredential, {
cid: '1',
userId: '123',
});
});
});
@@ -1,38 +1,21 @@
import { Get, RestController } from '@n8n/decorators';
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import { createHmac } from 'crypto';
import { Response } from 'express';
import { ensureError, jsonStringify } from 'n8n-workflow';
import type { RequestOptions } from 'oauth-1.0a';
import clientOAuth1 from 'oauth-1.0a';
import { OAuthRequest } from '@/requests';
import { OauthService, OauthVersion, skipAuthOnOAuthCallback } from '@/oauth/oauth.service';
import {
OauthService,
skipAuthOnOAuthCallback,
type OAuth1CredentialData,
} from '@/oauth/oauth.service';
import { Logger } from '@n8n/backend-common';
import { ExternalHooks } from '@/external-hooks';
interface OAuth1CredentialData {
signatureMethod: 'HMAC-SHA256' | 'HMAC-SHA512' | 'HMAC-SHA1';
consumerKey: string;
consumerSecret: string;
authUrl: string;
accessTokenUrl: string;
requestTokenUrl: string;
}
const algorithmMap = {
'HMAC-SHA256': 'sha256',
'HMAC-SHA512': 'sha512',
'HMAC-SHA1': 'sha1',
} as const;
@RestController('/oauth1-credential')
export class OAuth1CredentialController {
constructor(
private readonly oauthService: OauthService,
private readonly externalHooks: ExternalHooks,
private readonly logger: Logger,
) {}
@@ -40,67 +23,18 @@ export class OAuth1CredentialController {
@Get('/auth')
async getAuthUri(req: OAuthRequest.OAuth1Credential.Auth): Promise<string> {
const credential = await this.oauthService.getCredential(req);
const oauthCredentials =
await this.oauthService.getOAuthCredentials<OAuth1CredentialData>(credential);
const [csrfSecret, state] = this.oauthService.createCsrfState({
const uri = await this.oauthService.generateAOauth1AuthUri(credential, {
cid: credential.id,
userId: skipAuthOnOAuthCallback ? undefined : req.user.id,
});
const signatureMethod = oauthCredentials.signatureMethod;
const oAuthOptions: clientOAuth1.Options = {
consumer: {
key: oauthCredentials.consumerKey,
secret: oauthCredentials.consumerSecret,
},
signature_method: signatureMethod,
hash_function(base, key) {
const algorithm = algorithmMap[signatureMethod] ?? 'sha1';
return createHmac(algorithm, key).update(base).digest('base64');
},
};
const oauthRequestData = {
oauth_callback: `${this.oauthService.getBaseUrl(OauthVersion.V1)}/callback?state=${state}`,
};
await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]);
const oauth = new clientOAuth1(oAuthOptions);
const options: RequestOptions = {
method: 'POST',
url: oauthCredentials.requestTokenUrl,
data: oauthRequestData,
};
const data = oauth.toHeader(oauth.authorize(options));
// @ts-ignore
options.headers = data;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { data: response } = await axios.request(options as Partial<AxiosRequestConfig>);
// Response comes as x-www-form-urlencoded string so convert it to JSON
const paramsParser = new URLSearchParams(response as string);
const responseJson = Object.fromEntries(paramsParser.entries());
const returnUri = `${oauthCredentials.authUrl}?oauth_token=${responseJson.oauth_token}`;
await this.oauthService.encryptAndSaveData(credential, { csrfSecret });
this.logger.debug('OAuth1 authorization successful for new credential', {
userId: req.user.id,
credentialId: credential.id,
});
return returnUri;
return uri;
}
/** Verify and store app code. Generate access tokens and store for respective credential */
@@ -140,7 +74,6 @@ export class OAuth1CredentialController {
});
return res.render('oauth-callback');
} catch (e) {
console.log('error', e);
const error = ensureError(e);
return this.oauthService.renderCallbackError(
res,
@@ -24,10 +24,8 @@ export class OAuth2CredentialController {
@Get('/auth')
async getAuthUri(req: OAuthRequest.OAuth2Credential.Auth): Promise<string> {
const credential = await this.oauthService.getCredential(req);
const oauthCredentials: OAuth2CredentialData =
await this.oauthService.getOAuthCredentials<OAuth2CredentialData>(credential);
const uri = await this.oauthService.generateAOauth2AuthUri(credential, oauthCredentials, {
const uri = await this.oauthService.generateAOauth2AuthUri(credential, {
cid: credential.id,
userId: req.user.id,
});
@@ -71,15 +71,6 @@ describe('DynamicCredentialsController', () => {
const res = mock<Response>();
enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential);
oauthService.getOAuthCredentials.mockResolvedValueOnce({
clientId: 'client_id',
clientSecret: 'client_secret',
authUrl: 'https://example.domain/oauth2/auth',
accessTokenUrl: 'https://example.domain/oauth2/token',
scope: 'openid',
grantType: 'authorizationCode',
authentication: 'header',
});
oauthService.generateAOauth2AuthUri.mockResolvedValueOnce(
'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid',
);
@@ -88,45 +79,36 @@ describe('DynamicCredentialsController', () => {
expect(authUri).toContain('https://example.domain/oauth2/auth');
expect(enterpriseCredentialsService.getOne).toHaveBeenCalledWith('1');
expect(oauthService.getOAuthCredentials).toHaveBeenCalledWith(mockCredential);
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(
mockCredential,
expect.objectContaining({
clientId: 'client_id',
authUrl: 'https://example.domain/oauth2/auth',
}),
{
cid: '1',
authorizationHeader: 'Bearer token123',
},
);
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(mockCredential, {
cid: '1',
authorizationHeader: 'Bearer token123',
});
});
it('should throw BadRequestError for OAuth1 credential (not yet supported)', async () => {
it('should return auth URI for OAuth1 credential', async () => {
const mockCredential = mock<CredentialsEntity>({
id: '1',
type: 'twitterOAuth1Api',
});
const req = mock<Request>({
params: { id: '1' },
headers: {},
headers: { authorization: 'Bearer token123' },
});
const res = mock<Response>();
enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential);
oauthService.getOAuthCredentials.mockResolvedValueOnce({
clientId: 'client_id',
clientSecret: 'client_secret',
authUrl: 'https://example.domain/oauth1/auth',
accessTokenUrl: 'https://example.domain/oauth1/token',
});
await expect(controller.authorizeCredential(req, res)).rejects.toThrow(
'Credential type not supported',
oauthService.generateAOauth1AuthUri.mockResolvedValueOnce(
'https://example.domain/oauth/authorize?oauth_token=random-token',
);
const authUri = await controller.authorizeCredential(req, res);
expect(authUri).toContain('https://example.domain/oauth/authorize?oauth_token=random-token');
expect(enterpriseCredentialsService.getOne).toHaveBeenCalledWith('1');
expect(oauthService.getOAuthCredentials).toHaveBeenCalledWith(mockCredential);
expect(oauthService.generateAOauth2AuthUri).not.toHaveBeenCalled();
expect(oauthService.generateAOauth1AuthUri).toHaveBeenCalledWith(mockCredential, {
cid: '1',
authorizationHeader: 'Bearer token123',
});
});
it('should handle request without authorization header', async () => {
@@ -141,15 +123,6 @@ describe('DynamicCredentialsController', () => {
const res = mock<Response>();
enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential);
oauthService.getOAuthCredentials.mockResolvedValueOnce({
clientId: 'client_id',
clientSecret: 'client_secret',
authUrl: 'https://example.domain/oauth2/auth',
accessTokenUrl: 'https://example.domain/oauth2/token',
scope: 'openid',
grantType: 'authorizationCode',
authentication: 'header',
});
oauthService.generateAOauth2AuthUri.mockResolvedValueOnce(
'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid',
);
@@ -157,17 +130,10 @@ describe('DynamicCredentialsController', () => {
const authUri = await controller.authorizeCredential(req, res);
expect(authUri).toContain('https://example.domain/oauth2/auth');
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(
mockCredential,
expect.objectContaining({
clientId: 'client_id',
authUrl: 'https://example.domain/oauth2/auth',
}),
{
cid: '1',
authorizationHeader: undefined,
},
);
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(mockCredential, {
cid: '1',
authorizationHeader: undefined,
});
});
});
});
@@ -5,7 +5,6 @@ import { EnterpriseCredentialsService } from '@/credentials/credentials.service.
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { OauthService } from '@/oauth/oauth.service';
import { OAuth2CredentialData } from '@n8n/client-oauth2';
@RestController('/credentials')
export class DynamicCredentialsController {
@@ -26,11 +25,15 @@ export class DynamicCredentialsController {
throw new BadRequestError('Credential type not supported');
}
const oauthCredentials: OAuth2CredentialData =
await this.oauthService.getOAuthCredentials<OAuth2CredentialData>(credential);
if (credential.type.includes('OAuth2')) {
return await this.oauthService.generateAOauth2AuthUri(credential, oauthCredentials, {
return await this.oauthService.generateAOauth2AuthUri(credential, {
cid: credential.id,
authorizationHeader: req.headers.authorization,
});
}
if (credential.type.includes('OAuth1')) {
return await this.oauthService.generateAOauth1AuthUri(credential, {
cid: credential.id,
authorizationHeader: req.headers.authorization,
});
@@ -10,7 +10,12 @@ import type { Response } from 'express';
import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { UnexpectedError } from 'n8n-workflow';
import { OauthService, OauthVersion, shouldSkipAuthOnOAuthCallback } from '@/oauth/oauth.service';
import {
OauthService,
OauthVersion,
shouldSkipAuthOnOAuthCallback,
type OAuth1CredentialData,
} from '@/oauth/oauth.service';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsHelper } from '@/credentials-helper';
import { AuthError } from '@/errors/response-errors/auth.error';
@@ -720,7 +725,7 @@ describe('OauthService', () => {
}) as any,
);
const credential = mock<CredentialsEntity>({ id: '1' });
const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
const oauthCredentials: OAuth2CredentialData = {
clientId: 'client_id',
clientSecret: 'client_secret',
@@ -731,9 +736,10 @@ describe('OauthService', () => {
authentication: 'header',
};
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
const authUri = await service.generateAOauth2AuthUri(credential, oauthCredentials, {
const authUri = await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
});
@@ -769,7 +775,7 @@ describe('OauthService', () => {
}) as any,
);
const credential = mock<CredentialsEntity>({ id: '1' });
const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
const oauthCredentials: OAuth2CredentialData = {
clientId: 'client_id',
clientSecret: 'client_secret',
@@ -780,9 +786,10 @@ describe('OauthService', () => {
authentication: 'header',
};
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
const authUri = await service.generateAOauth2AuthUri(credential, oauthCredentials, {
const authUri = await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
});
@@ -811,7 +818,7 @@ describe('OauthService', () => {
}) as any,
);
const credential = mock<CredentialsEntity>({ id: '1' });
const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
const oauthCredentials: OAuth2CredentialData = {
clientId: 'client_id',
clientSecret: 'client_secret',
@@ -823,9 +830,10 @@ describe('OauthService', () => {
authQueryParameters: 'custom_param=value',
};
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
const authUri = await service.generateAOauth2AuthUri(credential, oauthCredentials, {
const authUri = await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
});
@@ -850,12 +858,13 @@ describe('OauthService', () => {
}) as any,
);
const credential = mock<CredentialsEntity>({ id: '1' });
const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
const oauthCredentials = {
serverUrl: 'https://example.domain',
useDynamicClientRegistration: true,
} as OAuth2CredentialData;
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
jest.mocked(axios.get).mockResolvedValue({
data: {
authorization_endpoint: 'https://example.domain/oauth2/auth',
@@ -877,7 +886,7 @@ describe('OauthService', () => {
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
const authUri = await service.generateAOauth2AuthUri(credential, oauthCredentials, {
const authUri = await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
});
@@ -913,24 +922,25 @@ describe('OauthService', () => {
it('should throw BadRequestError when OAuth2 server metadata is invalid', async () => {
const axios = require('axios');
const credential = mock<CredentialsEntity>({ id: '1' });
const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
const oauthCredentials = {
serverUrl: 'https://example.domain',
useDynamicClientRegistration: true,
} as OAuth2CredentialData;
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
jest.mocked(axios.get).mockResolvedValue({
data: { invalid: 'metadata' },
} as any);
await expect(
service.generateAOauth2AuthUri(credential, oauthCredentials, {
service.generateAOauth2AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
}),
).rejects.toThrow(BadRequestError);
await expect(
service.generateAOauth2AuthUri(credential, oauthCredentials, {
service.generateAOauth2AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
}),
@@ -942,12 +952,13 @@ describe('OauthService', () => {
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
jest.mocked(ClientOAuth2).mockImplementation(() => ({}) as any);
const credential = mock<CredentialsEntity>({ id: '1' });
const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
const oauthCredentials = {
serverUrl: 'https://example.domain',
useDynamicClientRegistration: true,
} as OAuth2CredentialData;
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
jest.mocked(axios.get).mockResolvedValue({
data: {
authorization_endpoint: 'https://example.domain/oauth2/auth',
@@ -964,13 +975,13 @@ describe('OauthService', () => {
} as any);
await expect(
service.generateAOauth2AuthUri(credential, oauthCredentials, {
service.generateAOauth2AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
}),
).rejects.toThrow(BadRequestError);
await expect(
service.generateAOauth2AuthUri(credential, oauthCredentials, {
service.generateAOauth2AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
}),
@@ -993,12 +1004,13 @@ describe('OauthService', () => {
}) as any,
);
const credential = mock<CredentialsEntity>({ id: '1' });
const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
const oauthCredentials = {
serverUrl: 'https://example.domain',
useDynamicClientRegistration: true,
} as OAuth2CredentialData;
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
jest.mocked(axios.get).mockResolvedValue({
data: {
authorization_endpoint: 'https://example.domain/oauth2/auth',
@@ -1019,7 +1031,7 @@ describe('OauthService', () => {
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
const authUri = await service.generateAOauth2AuthUri(credential, oauthCredentials, {
const authUri = await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
});
@@ -1049,7 +1061,7 @@ describe('OauthService', () => {
}) as any,
);
const credential = mock<CredentialsEntity>({ id: '1' });
const credential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
const oauthCredentials: OAuth2CredentialData = {
clientId: 'client_id',
clientSecret: 'client_secret',
@@ -1060,10 +1072,11 @@ describe('OauthService', () => {
authentication: 'header',
};
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
jest.spyOn(service, 'createCsrfState').mockReturnValue(['csrf-secret', 'encoded-state']);
await service.generateAOauth2AuthUri(credential, oauthCredentials, {
await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
});
@@ -1076,4 +1089,88 @@ describe('OauthService', () => {
);
});
});
describe('generateAOauth1AuthUri', () => {
it('should generate auth URI for OAuth1 credential', async () => {
const axios = require('axios');
const credential = mock<CredentialsEntity>({ id: '1', type: 'twitterOAuth1Api' });
const oauthCredentials: OAuth1CredentialData = {
consumerKey: 'consumer_key',
consumerSecret: 'consumer_secret',
requestTokenUrl: 'https://example.domain/oauth/request_token',
authUrl: 'https://example.domain/oauth/authorize',
accessTokenUrl: 'https://example.domain/oauth/access_token',
signatureMethod: 'HMAC-SHA1',
};
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
jest.mocked(axios.request).mockResolvedValue({
data: 'oauth_token=random-token&oauth_token_secret=random-secret',
});
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
const authUri = await service.generateAOauth1AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
});
expect(authUri).toContain('https://example.domain/oauth/authorize?oauth_token=random-token');
expect(service.encryptAndSaveData).toHaveBeenCalledWith(
credential,
expect.objectContaining({ csrfSecret: expect.any(String) }),
[],
);
expect(externalHooks.run).toHaveBeenCalledWith('oauth1.authenticate', expect.any(Array));
});
it('should generate auth URI with different signature methods', async () => {
const axios = require('axios');
const credential = mock<CredentialsEntity>({ id: '1', type: 'twitterOAuth1Api' });
const oauthCredentials: OAuth1CredentialData = {
consumerKey: 'consumer_key',
consumerSecret: 'consumer_secret',
requestTokenUrl: 'https://example.domain/oauth/request_token',
authUrl: 'https://example.domain/oauth/authorize',
accessTokenUrl: 'https://example.domain/oauth/access_token',
signatureMethod: 'HMAC-SHA256',
};
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
jest.mocked(axios.request).mockResolvedValue({
data: 'oauth_token=random-token&oauth_token_secret=random-secret',
});
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
const authUri = await service.generateAOauth1AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
});
expect(authUri).toContain('https://example.domain/oauth/authorize?oauth_token=random-token');
expect(service.encryptAndSaveData).toHaveBeenCalled();
});
it('should handle request token URL errors', async () => {
const axios = require('axios');
const credential = mock<CredentialsEntity>({ id: '1', type: 'twitterOAuth1Api' });
const oauthCredentials: OAuth1CredentialData = {
consumerKey: 'consumer_key',
consumerSecret: 'consumer_secret',
requestTokenUrl: 'https://example.domain/oauth/request_token',
authUrl: 'https://example.domain/oauth/authorize',
accessTokenUrl: 'https://example.domain/oauth/access_token',
signatureMethod: 'HMAC-SHA1',
};
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials);
jest.mocked(axios.request).mockRejectedValue(new Error('Request token failed'));
await expect(
service.generateAOauth1AuthUri(credential, {
cid: credential.id,
userId: 'user-id',
}),
).rejects.toThrow('Request token failed');
});
});
});
+97 -23
View File
@@ -1,6 +1,5 @@
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type { AuthenticatedRequest, CredentialsEntity, ICredentialsDb } from '@n8n/db';
import { CredentialsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
@@ -38,22 +37,18 @@ import pkceChallenge from 'pkce-challenge';
import * as qs from 'querystring';
import split from 'lodash/split';
import { ExternalHooks } from '@/external-hooks';
type CsrfStateRequired = {
/** Random CSRF token, used to verify the signature of the CSRF state */
token: string;
/** Creation timestamp of the CSRF state. Used for expiration. */
createdAt: number;
};
type CreateCsrfStateData = {
cid: string;
[key: string]: unknown;
};
type CsrfState = CsrfStateRequired & CreateCsrfStateData;
const MAX_CSRF_AGE = 5 * Time.minutes.toMilliseconds;
import type { AxiosRequestConfig } from 'axios';
import { createHmac } from 'crypto';
import type { RequestOptions } from 'oauth-1.0a';
import clientOAuth1 from 'oauth-1.0a';
import {
algorithmMap,
MAX_CSRF_AGE,
OauthVersion,
type CreateCsrfStateData,
type CsrfState,
type OAuth1CredentialData,
} from './types';
export function shouldSkipAuthOnOAuthCallback() {
const value = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK?.toLowerCase() ?? 'false';
@@ -62,10 +57,7 @@ export function shouldSkipAuthOnOAuthCallback() {
export const skipAuthOnOAuthCallback = shouldSkipAuthOnOAuthCallback();
export const enum OauthVersion {
V1 = 1,
V2 = 2,
}
export { OauthVersion, type OAuth1CredentialData, type CreateCsrfStateData, type CsrfState };
@Service()
export class OauthService {
@@ -84,7 +76,9 @@ export class OauthService {
return `${restUrl}/oauth${oauthVersion}-credential`;
}
async getCredential(req: OAuthRequest.OAuth2Credential.Auth): Promise<CredentialsEntity> {
async getCredential(
req: OAuthRequest.OAuth1Credential.Auth | OAuthRequest.OAuth2Credential.Auth,
): Promise<CredentialsEntity> {
const { id: credentialId } = req.query;
if (!credentialId) {
@@ -281,9 +275,11 @@ export class OauthService {
async generateAOauth2AuthUri(
credential: CredentialsEntity,
oauthCredentials: OAuth2CredentialData,
csrfData: CreateCsrfStateData,
): Promise<string> {
const oauthCredentials: OAuth2CredentialData =
await this.getOAuthCredentials<OAuth2CredentialData>(credential);
const toUpdate: ICredentialDataDecryptedObject = {};
if (oauthCredentials.useDynamicClientRegistration && oauthCredentials.serverUrl) {
@@ -397,6 +393,84 @@ export class OauthService {
return returnUri.toString();
}
async generateAOauth1AuthUri(
credential: CredentialsEntity,
csrfData: CreateCsrfStateData,
): Promise<string> {
const oauthCredentials: OAuth1CredentialData =
await this.getOAuthCredentials<OAuth1CredentialData>(credential);
const [csrfSecret, state] = this.createCsrfState(csrfData);
const signatureMethod = oauthCredentials.signatureMethod;
const oAuthOptions: clientOAuth1.Options = {
consumer: {
key: oauthCredentials.consumerKey,
secret: oauthCredentials.consumerSecret,
},
signature_method: signatureMethod,
hash_function(base, key) {
const algorithm = algorithmMap[signatureMethod] ?? 'sha1';
return createHmac(algorithm, key).update(base).digest('base64');
},
};
const oauthRequestData = {
oauth_callback: `${this.getBaseUrl(OauthVersion.V1)}/callback?state=${state}`,
};
await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]);
const oauth = new clientOAuth1(oAuthOptions);
const options: RequestOptions = {
method: 'POST',
url: oauthCredentials.requestTokenUrl,
data: oauthRequestData,
};
const data = oauth.toHeader(oauth.authorize(options));
const axiosConfig: AxiosRequestConfig = {
method: options.method,
url: options.url,
headers: {
...data,
},
};
const { data: response } = await axios.request(axiosConfig);
// Response comes as x-www-form-urlencoded string so convert it to JSON
if (typeof response !== 'string') {
throw new BadRequestError(
'Expected string response from OAuth1 request token endpoint, but received invalid response type',
);
}
const paramsParser = new URLSearchParams(response);
const responseJson = Object.fromEntries(paramsParser.entries());
if (!responseJson.oauth_token) {
throw new BadRequestError(
'OAuth1 request token response is missing required oauth_token parameter',
);
}
const returnUri = `${oauthCredentials.authUrl}?oauth_token=${responseJson.oauth_token}`;
await this.encryptAndSaveData(credential, { csrfSecret }, []);
this.logger.debug('OAuth1 authorization url created for credential', {
csrfData,
credentialId: credential.id,
});
return returnUri;
}
private convertCredentialToOptions(credential: OAuth2CredentialData): ClientOAuth2Options {
const options: ClientOAuth2Options = {
clientId: credential.clientId,
+37
View File
@@ -0,0 +1,37 @@
import { Time } from '@n8n/constants';
export type CsrfStateRequired = {
/** Random CSRF token, used to verify the signature of the CSRF state */
token: string;
/** Creation timestamp of the CSRF state. Used for expiration. */
createdAt: number;
};
export type CreateCsrfStateData = {
cid: string;
[key: string]: unknown;
};
export type CsrfState = CsrfStateRequired & CreateCsrfStateData;
export const MAX_CSRF_AGE = 5 * Time.minutes.toMilliseconds;
export const enum OauthVersion {
V1 = 1,
V2 = 2,
}
export interface OAuth1CredentialData {
signatureMethod: 'HMAC-SHA256' | 'HMAC-SHA512' | 'HMAC-SHA1';
consumerKey: string;
consumerSecret: string;
authUrl: string;
accessTokenUrl: string;
requestTokenUrl: string;
}
export const algorithmMap = {
'HMAC-SHA256': 'sha256',
'HMAC-SHA512': 'sha512',
'HMAC-SHA1': 'sha1',
} as const;