diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts index 2f48de84687..ac18c6d5fe3 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts @@ -32,24 +32,19 @@ describe('OAuth1CredentialController', () => { it('should return a valid auth URI', async () => { const mockResolvedCredential = mock({ 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({ user: mock({ 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', + }); }); }); diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index a0a2ee2f426..f58c2f79ae7 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -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', + }); }); }); diff --git a/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts index dca3b4b9594..65a5651367b 100644 --- a/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts @@ -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 { const credential = await this.oauthService.getCredential(req); - const oauthCredentials = - await this.oauthService.getOAuthCredentials(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); - - // 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, diff --git a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts index ac1b9268711..df0d7bee919 100644 --- a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts @@ -24,10 +24,8 @@ export class OAuth2CredentialController { @Get('/auth') async getAuthUri(req: OAuthRequest.OAuth2Credential.Auth): Promise { const credential = await this.oauthService.getCredential(req); - const oauthCredentials: OAuth2CredentialData = - await this.oauthService.getOAuthCredentials(credential); - const uri = await this.oauthService.generateAOauth2AuthUri(credential, oauthCredentials, { + const uri = await this.oauthService.generateAOauth2AuthUri(credential, { cid: credential.id, userId: req.user.id, }); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials.controller.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials.controller.test.ts index 9b35e2f3359..e0143c259fa 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials.controller.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials.controller.test.ts @@ -71,15 +71,6 @@ describe('DynamicCredentialsController', () => { const res = mock(); 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({ id: '1', type: 'twitterOAuth1Api', }); const req = mock({ params: { id: '1' }, - headers: {}, + headers: { authorization: 'Bearer token123' }, }); const res = mock(); 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(); 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, + }); }); }); }); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts index 12fa4477c80..fc0316f5f76 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts @@ -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(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, }); diff --git a/packages/cli/src/oauth/__tests__/oauth.service.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.test.ts index 4712d770956..b9a18dd15de 100644 --- a/packages/cli/src/oauth/__tests__/oauth.service.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth.service.test.ts @@ -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({ id: '1' }); + const credential = mock({ 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({ id: '1' }); + const credential = mock({ 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({ id: '1' }); + const credential = mock({ 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({ id: '1' }); + const credential = mock({ 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({ id: '1' }); + const credential = mock({ 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({ id: '1' }); + const credential = mock({ 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({ id: '1' }); + const credential = mock({ 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({ id: '1' }); + const credential = mock({ 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({ 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({ 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({ 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'); + }); + }); }); diff --git a/packages/cli/src/oauth/oauth.service.ts b/packages/cli/src/oauth/oauth.service.ts index 91538e50b99..de88b654ab7 100644 --- a/packages/cli/src/oauth/oauth.service.ts +++ b/packages/cli/src/oauth/oauth.service.ts @@ -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 { + async getCredential( + req: OAuthRequest.OAuth1Credential.Auth | OAuthRequest.OAuth2Credential.Auth, + ): Promise { const { id: credentialId } = req.query; if (!credentialId) { @@ -281,9 +275,11 @@ export class OauthService { async generateAOauth2AuthUri( credential: CredentialsEntity, - oauthCredentials: OAuth2CredentialData, csrfData: CreateCsrfStateData, ): Promise { + const oauthCredentials: OAuth2CredentialData = + await this.getOAuthCredentials(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 { + const oauthCredentials: OAuth1CredentialData = + await this.getOAuthCredentials(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, diff --git a/packages/cli/src/oauth/types.ts b/packages/cli/src/oauth/types.ts new file mode 100644 index 00000000000..512ffe37b78 --- /dev/null +++ b/packages/cli/src/oauth/types.ts @@ -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;