mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat: Support oauth1 authorization for dynamic credentials (#22972)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
+20
-54
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user