feat: Add support for dynamic credentials to oauth callback (#23013)

This commit is contained in:
Stephen Wright
2025-12-11 08:28:03 +00:00
committed by GitHub
parent 1c2e89fa52
commit ce317e30a8
9 changed files with 1037 additions and 39 deletions
@@ -43,6 +43,7 @@ describe('OAuth1CredentialController', () => {
expect(authUri).toEqual('https://example.domain/oauth/authorize?oauth_token=random-token');
expect(oauthService.generateAOauth1AuthUri).toHaveBeenCalledWith(mockResolvedCredential, {
cid: '1',
origin: 'static-credential',
userId: '123',
});
});
@@ -53,6 +54,7 @@ describe('OAuth1CredentialController', () => {
JSON.stringify({
token: 'token',
cid: '1',
origin: 'static-credential',
createdAt: timestamp,
}),
).toString('base64');
@@ -78,8 +80,14 @@ describe('OAuth1CredentialController', () => {
);
});
it('should exchange the code for a valid token, and save it to DB', async () => {
it('should exchange the code for a valid token, and save it to DB for static credential', async () => {
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
origin: 'static-credential' as const,
createdAt: timestamp,
};
oauthService.getCredential.mockResolvedValueOnce(mockResolvedCredential);
// @ts-ignore
oauthService.getDecryptedData.mockResolvedValue({ csrfSecret: 'invalid' });
@@ -92,15 +100,220 @@ describe('OAuth1CredentialController', () => {
mockResolvedCredential,
{ csrfSecret: 'invalid' },
{ accessTokenUrl: 'https://example.domain/oauth/access_token' },
mockState,
]);
jest.mocked(axios).post.mockResolvedValueOnce({ data: { access_token: 'new_token' } });
jest
.mocked(axios.post)
.mockResolvedValueOnce({ data: 'oauth_token=token&oauth_token_secret=secret' } as any);
await controller.handleCallback(req, res);
// @ts-ignore
expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
mockResolvedCredential,
expect.objectContaining({
oauthTokenData: { access_token: 'new_token' },
oauthTokenData: expect.objectContaining({
oauth_token: 'token',
oauth_token_secret: 'secret',
}),
}),
['csrfSecret'],
);
expect(res.render).toHaveBeenCalledWith('oauth-callback');
});
it('should handle dynamic credential callback successfully', async () => {
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
origin: 'dynamic-credential' as const,
credentialResolverId: 'resolver-id',
authorizationHeader: 'Bearer token123',
createdAt: timestamp,
};
const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
const dynamicReq = mock<OAuthRequest.OAuth1Credential.Callback>({
query: {
oauth_verifier: 'verifier',
oauth_token: 'token',
state: dynamicState,
},
});
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'invalid' },
{ accessTokenUrl: 'https://example.domain/oauth/access_token' },
mockState,
]);
jest
.mocked(axios.post)
.mockResolvedValueOnce({ data: 'oauth_token=token&oauth_token_secret=secret' } as any);
oauthService.saveDynamicCredential.mockResolvedValueOnce(undefined);
await controller.handleCallback(dynamicReq, res);
expect(oauthService.saveDynamicCredential).toHaveBeenCalledWith(
mockResolvedCredential,
expect.objectContaining({
oauth_token: 'token',
oauth_token_secret: 'secret',
}),
'token123',
'resolver-id',
);
expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled();
expect(res.render).toHaveBeenCalledWith('oauth-callback');
});
it('should render error when credentialResolverId is missing for dynamic credential', async () => {
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
origin: 'dynamic-credential' as const,
authorizationHeader: 'Bearer token123',
createdAt: timestamp,
};
const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
const dynamicReq = mock<OAuthRequest.OAuth1Credential.Callback>({
query: {
oauth_verifier: 'verifier',
oauth_token: 'token',
state: dynamicState,
},
});
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'invalid' },
{ accessTokenUrl: 'https://example.domain/oauth/access_token' },
mockState,
]);
jest
.mocked(axios.post)
.mockResolvedValueOnce({ data: 'oauth_token=token&oauth_token_secret=secret' } as any);
await controller.handleCallback(dynamicReq, res);
expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
res,
'Credential resolver ID is required',
);
expect(oauthService.saveDynamicCredential).not.toHaveBeenCalled();
});
it('should render error when authorizationHeader is missing for dynamic credential', async () => {
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
origin: 'dynamic-credential' as const,
credentialResolverId: 'resolver-id',
createdAt: timestamp,
};
const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
const dynamicReq = mock<OAuthRequest.OAuth1Credential.Callback>({
query: {
oauth_verifier: 'verifier',
oauth_token: 'token',
state: dynamicState,
},
});
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'invalid' },
{ accessTokenUrl: 'https://example.domain/oauth/access_token' },
mockState,
]);
jest
.mocked(axios.post)
.mockResolvedValueOnce({ data: 'oauth_token=token&oauth_token_secret=secret' } as any);
await controller.handleCallback(dynamicReq, res);
expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
res,
'Authorization header is required',
);
expect(oauthService.saveDynamicCredential).not.toHaveBeenCalled();
});
it('should render error when authorizationHeader does not start with Bearer', async () => {
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
origin: 'dynamic-credential' as const,
credentialResolverId: 'resolver-id',
authorizationHeader: 'Invalid token123',
createdAt: timestamp,
};
const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
const dynamicReq = mock<OAuthRequest.OAuth1Credential.Callback>({
query: {
oauth_verifier: 'verifier',
oauth_token: 'token',
state: dynamicState,
},
});
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'invalid' },
{ accessTokenUrl: 'https://example.domain/oauth/access_token' },
mockState,
]);
jest
.mocked(axios.post)
.mockResolvedValueOnce({ data: 'oauth_token=token&oauth_token_secret=secret' } as any);
await controller.handleCallback(dynamicReq, res);
expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
res,
'Authorization header is required',
);
expect(oauthService.saveDynamicCredential).not.toHaveBeenCalled();
});
it('should handle static credential callback when origin is undefined', async () => {
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
origin: 'static-credential' as const,
createdAt: timestamp,
};
const undefinedOriginState = Buffer.from(JSON.stringify(mockState)).toString('base64');
const undefinedOriginReq = mock<OAuthRequest.OAuth1Credential.Callback>({
query: {
oauth_verifier: 'verifier',
oauth_token: 'token',
state: undefinedOriginState,
},
});
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'invalid' },
{ accessTokenUrl: 'https://example.domain/oauth/access_token' },
mockState,
]);
jest
.mocked(axios.post)
.mockResolvedValueOnce({ data: 'oauth_token=token&oauth_token_secret=secret' } as any);
await controller.handleCallback(undefinedOriginReq, res);
expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
mockResolvedCredential,
expect.objectContaining({
oauthTokenData: expect.objectContaining({
oauth_token: 'token',
oauth_token_secret: 'secret',
}),
}),
['csrfSecret'],
);
@@ -68,6 +68,7 @@ describe('OAuth2CredentialController', () => {
expect(authUri).toContain('https://example.domain/oauth2/auth');
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(mockResolvedCredential, {
cid: '1',
origin: 'static-credential',
userId: '123',
});
});
@@ -79,6 +80,7 @@ describe('OAuth2CredentialController', () => {
token: 'token',
cid: '1',
userId: '123',
origin: 'static-credential',
createdAt: timestamp,
}),
).toString('base64');
@@ -97,7 +99,7 @@ describe('OAuth2CredentialController', () => {
);
});
it('should exchange the code for a valid token, and save it to DB', async () => {
it('should exchange the code for a valid token, and save it to DB for static credential', async () => {
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
const mockGetToken = jest.fn().mockResolvedValue({
data: { access_token: 'new_token', refresh_token: 'refresh_token' },
@@ -112,6 +114,13 @@ describe('OAuth2CredentialController', () => {
);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
userId: '123',
origin: 'static-credential' as const,
createdAt: timestamp,
};
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'csrf-secret' },
@@ -124,6 +133,7 @@ describe('OAuth2CredentialController', () => {
grantType: 'authorizationCode',
authentication: 'header',
},
mockState,
]);
oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
externalHooks.run.mockResolvedValue(undefined);
@@ -149,6 +159,306 @@ describe('OAuth2CredentialController', () => {
expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', expect.any(Array));
});
it('should handle dynamic credential callback successfully', async () => {
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
const mockGetToken = jest.fn().mockResolvedValue({
data: { access_token: 'new_token', refresh_token: 'refresh_token' },
});
jest.mocked(ClientOAuth2).mockImplementation(
() =>
({
code: {
getToken: mockGetToken,
},
}) as any,
);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockDecryptedData = { csrfSecret: 'csrf-secret', existing: 'data' };
const mockState = {
token: 'token',
cid: '1',
userId: '123',
origin: 'dynamic-credential' as const,
credentialResolverId: 'resolver-id',
authorizationHeader: 'Bearer token123',
createdAt: timestamp,
};
const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
mockDecryptedData,
{
clientId: 'client_id',
clientSecret: 'client_secret',
authUrl: 'https://example.domain/oauth2/auth',
accessTokenUrl: 'https://example.domain/oauth2/token',
scope: 'openid',
grantType: 'authorizationCode',
authentication: 'header',
},
mockState,
]);
oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
externalHooks.run.mockResolvedValue(undefined);
oauthService.saveDynamicCredential.mockResolvedValueOnce(undefined);
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: {
code: 'auth_code',
state: dynamicState,
},
originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
});
await controller.handleCallback(req, res);
// The controller passes decryptedDataOriginal directly, not the merged oauthTokenData
expect(oauthService.saveDynamicCredential).toHaveBeenCalledWith(
mockResolvedCredential,
mockDecryptedData,
'token123',
'resolver-id',
);
expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled();
expect(res.render).toHaveBeenCalledWith('oauth-callback');
});
it('should render error when credentialResolverId is missing for dynamic credential', async () => {
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
const mockGetToken = jest.fn().mockResolvedValue({
data: { access_token: 'new_token', refresh_token: 'refresh_token' },
});
jest.mocked(ClientOAuth2).mockImplementation(
() =>
({
code: {
getToken: mockGetToken,
},
}) as any,
);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
userId: '123',
origin: 'dynamic-credential' as const,
authorizationHeader: 'Bearer token123',
createdAt: timestamp,
};
const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'csrf-secret' },
{
clientId: 'client_id',
clientSecret: 'client_secret',
authUrl: 'https://example.domain/oauth2/auth',
accessTokenUrl: 'https://example.domain/oauth2/token',
scope: 'openid',
grantType: 'authorizationCode',
authentication: 'header',
},
mockState,
]);
oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
externalHooks.run.mockResolvedValue(undefined);
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: {
code: 'auth_code',
state: dynamicState,
},
originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
});
await controller.handleCallback(req, res);
expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
res,
'Credential resolver ID is required',
);
expect(oauthService.saveDynamicCredential).not.toHaveBeenCalled();
});
it('should render error when authorizationHeader is missing for dynamic credential', async () => {
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
const mockGetToken = jest.fn().mockResolvedValue({
data: { access_token: 'new_token', refresh_token: 'refresh_token' },
});
jest.mocked(ClientOAuth2).mockImplementation(
() =>
({
code: {
getToken: mockGetToken,
},
}) as any,
);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
userId: '123',
origin: 'dynamic-credential' as const,
credentialResolverId: 'resolver-id',
createdAt: timestamp,
};
const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'csrf-secret' },
{
clientId: 'client_id',
clientSecret: 'client_secret',
authUrl: 'https://example.domain/oauth2/auth',
accessTokenUrl: 'https://example.domain/oauth2/token',
scope: 'openid',
grantType: 'authorizationCode',
authentication: 'header',
},
mockState,
]);
oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
externalHooks.run.mockResolvedValue(undefined);
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: {
code: 'auth_code',
state: dynamicState,
},
originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
});
await controller.handleCallback(req, res);
expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
res,
'Authorization header is required',
);
expect(oauthService.saveDynamicCredential).not.toHaveBeenCalled();
});
it('should render error when authorizationHeader does not start with Bearer', async () => {
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
const mockGetToken = jest.fn().mockResolvedValue({
data: { access_token: 'new_token', refresh_token: 'refresh_token' },
});
jest.mocked(ClientOAuth2).mockImplementation(
() =>
({
code: {
getToken: mockGetToken,
},
}) as any,
);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
userId: '123',
origin: 'dynamic-credential' as const,
credentialResolverId: 'resolver-id',
authorizationHeader: 'Invalid token123',
createdAt: timestamp,
};
const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'csrf-secret' },
{
clientId: 'client_id',
clientSecret: 'client_secret',
authUrl: 'https://example.domain/oauth2/auth',
accessTokenUrl: 'https://example.domain/oauth2/token',
scope: 'openid',
grantType: 'authorizationCode',
authentication: 'header',
},
mockState,
]);
oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
externalHooks.run.mockResolvedValue(undefined);
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: {
code: 'auth_code',
state: dynamicState,
},
originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
});
await controller.handleCallback(req, res);
expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
res,
'Authorization header is required',
);
expect(oauthService.saveDynamicCredential).not.toHaveBeenCalled();
});
it('should handle static credential callback when origin is undefined', async () => {
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
const mockGetToken = jest.fn().mockResolvedValue({
data: { access_token: 'new_token', refresh_token: 'refresh_token' },
});
jest.mocked(ClientOAuth2).mockImplementation(
() =>
({
code: {
getToken: mockGetToken,
},
}) as any,
);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
userId: '123',
origin: 'static-credential' as const,
createdAt: timestamp,
};
const undefinedOriginState = Buffer.from(JSON.stringify(mockState)).toString('base64');
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'csrf-secret' },
{
clientId: 'client_id',
clientSecret: 'client_secret',
authUrl: 'https://example.domain/oauth2/auth',
accessTokenUrl: 'https://example.domain/oauth2/token',
scope: 'openid',
grantType: 'authorizationCode',
authentication: 'header',
},
mockState,
]);
oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
externalHooks.run.mockResolvedValue(undefined);
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: {
code: 'auth_code',
state: undefinedOriginState,
},
originalUrl: '/oauth2-credential/callback?code=auth_code&state=state',
});
await controller.handleCallback(req, res);
expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith(
mockResolvedCredential,
expect.objectContaining({
oauthTokenData: { access_token: 'new_token', refresh_token: 'refresh_token' },
}),
['csrfSecret'],
);
expect(res.render).toHaveBeenCalledWith('oauth-callback');
});
it('should handle PKCE flow', async () => {
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
const mockGetToken = jest.fn().mockResolvedValue({
@@ -164,6 +474,13 @@ describe('OAuth2CredentialController', () => {
);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
userId: '123',
origin: 'static-credential' as const,
createdAt: timestamp,
};
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'csrf-secret', codeVerifier: 'code_verifier' },
@@ -176,6 +493,7 @@ describe('OAuth2CredentialController', () => {
grantType: 'pkce',
authentication: 'header',
},
mockState,
]);
oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
externalHooks.run.mockResolvedValue(undefined);
@@ -214,6 +532,13 @@ describe('OAuth2CredentialController', () => {
);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
userId: '123',
origin: 'static-credential' as const,
createdAt: timestamp,
};
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'csrf-secret' },
@@ -226,6 +551,7 @@ describe('OAuth2CredentialController', () => {
grantType: 'authorizationCode',
authentication: 'body',
},
mockState,
]);
oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
externalHooks.run.mockResolvedValue(undefined);
@@ -266,6 +592,13 @@ describe('OAuth2CredentialController', () => {
);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
userId: '123',
origin: 'static-credential' as const,
createdAt: timestamp,
};
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'csrf-secret' },
@@ -278,6 +611,7 @@ describe('OAuth2CredentialController', () => {
grantType: 'authorizationCode',
authentication: 'header',
},
mockState,
]);
oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
externalHooks.run.mockResolvedValue(undefined);
@@ -321,6 +655,13 @@ describe('OAuth2CredentialController', () => {
);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {
token: 'token',
cid: '1',
userId: '123',
origin: 'static-credential' as const,
createdAt: timestamp,
};
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'csrf-secret' },
@@ -333,6 +674,7 @@ describe('OAuth2CredentialController', () => {
grantType: 'authorizationCode',
authentication: 'header',
},
mockState,
]);
oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential');
externalHooks.run.mockResolvedValue(undefined);
@@ -26,6 +26,7 @@ export class OAuth1CredentialController {
const uri = await this.oauthService.generateAOauth1AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: skipAuthOnOAuthCallback ? undefined : req.user.id,
});
@@ -51,7 +52,7 @@ export class OAuth1CredentialController {
);
}
const [credential, _, oauthCredentials] =
const [credential, _, oauthCredentials, state] =
await this.oauthService.resolveCredential<OAuth1CredentialData>(req);
// Form URL encoded body https://datatracker.ietf.org/doc/html/rfc5849#section-3.5.2
@@ -67,12 +68,36 @@ export class OAuth1CredentialController {
const oauthTokenData = Object.fromEntries(paramParser.entries());
await this.oauthService.encryptAndSaveData(credential, { oauthTokenData }, ['csrfSecret']);
if (!state.origin || state.origin === 'static-credential') {
await this.oauthService.encryptAndSaveData(credential, { oauthTokenData }, ['csrfSecret']);
this.logger.debug('OAuth1 callback successful for new credential', {
credentialId: credential.id,
});
return res.render('oauth-callback');
this.logger.debug('OAuth1 callback successful for new credential', {
credentialId: credential.id,
});
return res.render('oauth-callback');
}
if (state.origin === 'dynamic-credential') {
if (!state.credentialResolverId || typeof state.credentialResolverId !== 'string') {
return this.oauthService.renderCallbackError(res, 'Credential resolver ID is required');
}
if (
!state.authorizationHeader ||
typeof state.authorizationHeader !== 'string' ||
!state.authorizationHeader.startsWith('Bearer ')
) {
return this.oauthService.renderCallbackError(res, 'Authorization header is required');
}
await this.oauthService.saveDynamicCredential(
credential,
oauthTokenData,
state.authorizationHeader.split('Bearer ')[1],
state.credentialResolverId,
);
return res.render('oauth-callback');
}
} catch (e) {
const error = ensureError(e);
return this.oauthService.renderCallbackError(
@@ -11,6 +11,7 @@ import { OAuthRequest } from '@/requests';
import { OauthService, OauthVersion, skipAuthOnOAuthCallback } from '@/oauth/oauth.service';
import { Logger } from '@n8n/backend-common';
import { ExternalHooks } from '@/external-hooks';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
@RestController('/oauth2-credential')
export class OAuth2CredentialController {
@@ -27,6 +28,7 @@ export class OAuth2CredentialController {
const uri = await this.oauthService.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: req.user.id,
});
return uri;
@@ -45,7 +47,7 @@ export class OAuth2CredentialController {
);
}
const [credential, decryptedDataOriginal, oauthCredentials] =
const [credential, decryptedDataOriginal, oauthCredentials, state] =
await this.oauthService.resolveCredential<OAuth2CredentialData>(req);
let options: Partial<ClientOAuth2Options> = {};
@@ -88,15 +90,39 @@ export class OAuth2CredentialController {
oauthTokenData = {
...(typeof oauthTokenData === 'object' ? oauthTokenData : {}),
...oauthToken.data,
};
} as ICredentialDataDecryptedObject;
await this.oauthService.encryptAndSaveData(credential, { oauthTokenData }, ['csrfSecret']);
if (!state.origin || state.origin === 'static-credential') {
await this.oauthService.encryptAndSaveData(credential, { oauthTokenData }, ['csrfSecret']);
this.logger.debug('OAuth2 callback successful for credential', {
credentialId: credential.id,
});
this.logger.debug('OAuth2 callback successful for credential', {
credentialId: credential.id,
});
return res.render('oauth-callback');
return res.render('oauth-callback');
}
if (state.origin === 'dynamic-credential') {
if (!state.credentialResolverId || typeof state.credentialResolverId !== 'string') {
return this.oauthService.renderCallbackError(res, 'Credential resolver ID is required');
}
if (
!state.authorizationHeader ||
typeof state.authorizationHeader !== 'string' ||
!state.authorizationHeader.startsWith('Bearer ')
) {
return this.oauthService.renderCallbackError(res, 'Authorization header is required');
}
await this.oauthService.saveDynamicCredential(
credential,
decryptedDataOriginal,
state.authorizationHeader.split('Bearer ')[1],
state.credentialResolverId,
);
return res.render('oauth-callback');
}
} catch (e) {
const error = ensureError(e);
return this.oauthService.renderCallbackError(
@@ -81,6 +81,7 @@ describe('DynamicCredentialsController', () => {
expect(enterpriseCredentialsService.getOne).toHaveBeenCalledWith('1');
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(mockCredential, {
cid: '1',
origin: 'dynamic-credential',
authorizationHeader: 'Bearer token123',
});
});
@@ -107,6 +108,7 @@ describe('DynamicCredentialsController', () => {
expect(enterpriseCredentialsService.getOne).toHaveBeenCalledWith('1');
expect(oauthService.generateAOauth1AuthUri).toHaveBeenCalledWith(mockCredential, {
cid: '1',
origin: 'dynamic-credential',
authorizationHeader: 'Bearer token123',
});
});
@@ -132,7 +134,8 @@ describe('DynamicCredentialsController', () => {
expect(authUri).toContain('https://example.domain/oauth2/auth');
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(mockCredential, {
cid: '1',
authorizationHeader: undefined,
origin: 'dynamic-credential',
authorizationHeader: '',
});
});
});
@@ -28,14 +28,16 @@ export class DynamicCredentialsController {
if (credential.type.includes('OAuth2')) {
return await this.oauthService.generateAOauth2AuthUri(credential, {
cid: credential.id,
authorizationHeader: req.headers.authorization,
origin: 'dynamic-credential',
authorizationHeader: req.headers.authorization ?? '',
});
}
if (credential.type.includes('OAuth1')) {
return await this.oauthService.generateAOauth1AuthUri(credential, {
cid: credential.id,
authorizationHeader: req.headers.authorization,
origin: 'dynamic-credential',
authorizationHeader: req.headers.authorization ?? '',
});
}
@@ -9,6 +9,7 @@ import { mock } from 'jest-mock-extended';
import type { Response } from 'express';
import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { UnexpectedError } from 'n8n-workflow';
import type { Cipher } from 'n8n-core';
import {
OauthService,
@@ -26,6 +27,8 @@ import { UrlService } from '@/services/url.service';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import { ExternalHooks } from '@/external-hooks';
import type { OAuth2CredentialData } from '@n8n/client-oauth2';
import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy';
import { Credentials } from 'n8n-core';
jest.mock('@/workflow-execute-additional-data');
jest.mock('axios');
@@ -40,6 +43,8 @@ describe('OauthService', () => {
const urlService = mockInstance(UrlService);
const globalConfig = mockInstance(GlobalConfig);
const externalHooks = mockInstance(ExternalHooks);
const cipher = mock<Cipher>();
const dynamicCredentialsProxy = mockInstance(DynamicCredentialsProxy);
let service: OauthService;
@@ -62,6 +67,17 @@ describe('OauthService', () => {
axios.get = jest.fn();
axios.post = jest.fn();
// Setup cipher mock - encrypt returns the input as-is for testing, decrypt does the reverse
cipher.encrypt.mockImplementation((data: string) => {
// For testing, we'll use base64 encoding as a simple mock
// In production, this would be actual encryption
return Buffer.from(data).toString('base64');
});
cipher.decrypt.mockImplementation((data: string) => {
// For testing, decode the base64
return Buffer.from(data, 'base64').toString();
});
service = new OauthService(
logger,
credentialsHelper,
@@ -70,6 +86,8 @@ describe('OauthService', () => {
urlService,
globalConfig,
externalHooks,
cipher,
dynamicCredentialsProxy,
);
});
@@ -316,15 +334,20 @@ describe('OauthService', () => {
describe('createCsrfState', () => {
it('should create CSRF state with correct structure', () => {
const data = { cid: 'credential-id', userId: 'user-id' };
const data = {
cid: 'credential-id',
userId: 'user-id',
origin: 'static-credential' as const,
};
jest.setSystemTime(new Date(timestamp));
const [csrfSecret, encodedState] = service.createCsrfState(data);
expect(typeof csrfSecret).toBe('string');
expect(csrfSecret.length).toBeGreaterThan(0);
expect(cipher.encrypt).toHaveBeenCalled();
const decoded = JSON.parse(Buffer.from(encodedState, 'base64').toString());
const decoded = JSON.parse(cipher.decrypt(encodedState));
expect(decoded.cid).toBe('credential-id');
expect(decoded.userId).toBe('user-id');
expect(decoded.token).toBeDefined();
@@ -332,12 +355,17 @@ describe('OauthService', () => {
});
it('should include additional data in state', () => {
const data = { cid: 'credential-id', customField: 'custom-value' };
const data = {
cid: 'credential-id',
customField: 'custom-value',
origin: 'static-credential' as const,
};
jest.setSystemTime(new Date(timestamp));
const [, encodedState] = service.createCsrfState(data);
const decoded = JSON.parse(Buffer.from(encodedState, 'base64').toString());
expect(cipher.encrypt).toHaveBeenCalled();
const decoded = JSON.parse(cipher.decrypt(encodedState));
expect(decoded.customField).toBe('custom-value');
});
});
@@ -348,15 +376,18 @@ describe('OauthService', () => {
token: 'token',
cid: 'credential-id',
userId: 'user-id',
origin: 'static-credential' as const,
createdAt: timestamp,
};
const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const req = mock<AuthenticatedRequest>({
user: mock<User>({ id: 'user-id' }),
});
const result = (service as any).decodeCsrfState(encodedState, req);
expect(cipher.decrypt).toHaveBeenCalledWith(encodedState);
expect(result).toEqual(state);
});
@@ -376,7 +407,8 @@ describe('OauthService', () => {
token: 'token',
createdAt: timestamp,
};
const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const req = mock<AuthenticatedRequest>({
user: mock<User>({ id: 'user-id' }),
});
@@ -389,7 +421,8 @@ describe('OauthService', () => {
cid: 'credential-id',
createdAt: timestamp,
};
const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const req = mock<AuthenticatedRequest>({
user: mock<User>({ id: 'user-id' }),
});
@@ -402,9 +435,11 @@ describe('OauthService', () => {
token: 'token',
cid: 'credential-id',
userId: 'different-user-id',
origin: 'static-credential' as const,
createdAt: timestamp,
};
const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const req = mock<AuthenticatedRequest>({
user: mock<User>({ id: 'user-id' }),
});
@@ -418,15 +453,110 @@ describe('OauthService', () => {
token: 'token',
cid: 'credential-id',
userId: 'user-id',
origin: 'static-credential' as const,
createdAt: timestamp,
};
const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const req = mock<AuthenticatedRequest>({
user: undefined,
});
expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow(AuthError);
});
it('should bypass user validation for dynamic-credential origin', () => {
const state = {
token: 'token',
cid: 'credential-id',
userId: 'different-user-id',
origin: 'dynamic-credential' as const,
createdAt: timestamp,
};
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const req = mock<AuthenticatedRequest>({
user: mock<User>({ id: 'user-id' }),
});
const result = (service as any).decodeCsrfState(encodedState, req);
expect(result).toEqual(state);
expect(cipher.decrypt).toHaveBeenCalledWith(encodedState);
});
it('should bypass user validation for dynamic-credential origin even when req.user is undefined', () => {
const state = {
token: 'token',
cid: 'credential-id',
userId: 'user-id',
origin: 'dynamic-credential' as const,
createdAt: timestamp,
};
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const req = mock<AuthenticatedRequest>({
user: undefined,
});
const result = (service as any).decodeCsrfState(encodedState, req);
expect(result).toEqual(state);
expect(cipher.decrypt).toHaveBeenCalledWith(encodedState);
});
it('should require user validation for static-credential origin', () => {
const state = {
token: 'token',
cid: 'credential-id',
userId: 'different-user-id',
origin: 'static-credential' as const,
createdAt: timestamp,
};
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const req = mock<AuthenticatedRequest>({
user: mock<User>({ id: 'user-id' }),
});
expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow(AuthError);
expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow('Unauthorized');
});
it('should require user validation when origin is undefined', () => {
const state = {
token: 'token',
cid: 'credential-id',
userId: 'different-user-id',
createdAt: timestamp,
};
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const req = mock<AuthenticatedRequest>({
user: mock<User>({ id: 'user-id' }),
});
expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow(AuthError);
expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow('Unauthorized');
});
it('should require user validation for invalid origin values', () => {
const state = {
token: 'token',
cid: 'credential-id',
userId: 'different-user-id',
origin: 'invalid-origin' as any,
createdAt: timestamp,
};
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const req = mock<AuthenticatedRequest>({
user: mock<User>({ id: 'user-id' }),
});
expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow(AuthError);
expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow('Unauthorized');
});
});
describe('verifyCsrfState', () => {
@@ -438,6 +568,25 @@ describe('OauthService', () => {
const state = {
token: stateToken,
cid: 'credential-id',
origin: 'static-credential',
createdAt: Date.now(),
};
const decrypted = { csrfSecret };
const result = (service as any).verifyCsrfState(decrypted, state);
expect(result).toBe(true);
});
it('should return true for valid CSRF state with dynamic credential origin', () => {
const csrfSecret = 'csrf-secret';
const token = new (require('csrf'))();
const stateToken = token.create(csrfSecret);
const state = {
token: stateToken,
cid: 'credential-id',
origin: 'dynamic-credential',
createdAt: Date.now(),
};
const decrypted = { csrfSecret };
@@ -456,6 +605,7 @@ describe('OauthService', () => {
const state = {
token: stateToken,
cid: 'credential-id',
origin: 'static-credential',
createdAt: expiredTime,
};
const decrypted = { csrfSecret };
@@ -473,6 +623,7 @@ describe('OauthService', () => {
const state = {
token: stateToken,
cid: 'credential-id',
origin: 'static-credential',
createdAt: Date.now(),
};
const decrypted = {};
@@ -486,6 +637,7 @@ describe('OauthService', () => {
const state = {
token: 'invalid-token',
cid: 'credential-id',
origin: 'static-credential',
createdAt: Date.now(),
};
const decrypted = { csrfSecret: 'csrf-secret' };
@@ -502,6 +654,7 @@ describe('OauthService', () => {
token: 'token',
cid: 'credential-id',
userId: 'user-id',
origin: 'static-credential',
createdAt: timestamp,
};
@@ -515,7 +668,7 @@ describe('OauthService', () => {
state.token = stateToken;
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { state: Buffer.from(JSON.stringify(state)).toString('base64') },
query: { state: cipher.encrypt(JSON.stringify(state)) },
user: mock<User>({ id: 'user-id' }),
});
@@ -528,7 +681,7 @@ describe('OauthService', () => {
const result = await service.resolveCredential(req);
expect(result).toEqual([mockCredential, mockDecryptedData, mockOAuthCredentials]);
expect(result).toEqual([mockCredential, mockDecryptedData, mockOAuthCredentials, state]);
});
it('should throw UnexpectedError when credential is not found', async () => {
@@ -536,9 +689,11 @@ describe('OauthService', () => {
token: 'token',
cid: 'credential-id',
userId: 'user-id',
origin: 'static-credential',
createdAt: timestamp,
};
const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { state: encodedState },
@@ -558,9 +713,11 @@ describe('OauthService', () => {
token: 'token',
cid: 'credential-id',
userId: 'user-id',
origin: 'static-credential',
createdAt: timestamp,
};
const encodedState = Buffer.from(JSON.stringify(state)).toString('base64');
const stateString = JSON.stringify(state);
const encodedState = cipher.encrypt(stateString);
const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
const mockDecryptedData = { csrfSecret: 'csrf-secret' };
@@ -584,6 +741,180 @@ describe('OauthService', () => {
'The OAuth callback state is invalid!',
);
});
it('should resolve dynamic credential without user validation but still verify CSRF', async () => {
const state = {
token: 'token',
cid: 'credential-id',
userId: 'different-user-id',
origin: 'dynamic-credential' as const,
createdAt: timestamp,
};
const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
const mockDecryptedData = { csrfSecret: 'csrf-secret' };
const mockOAuthCredentials = { clientId: 'client-id' };
const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();
const token = new (require('csrf'))();
const stateToken = token.create('csrf-secret');
state.token = stateToken;
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { state: cipher.encrypt(JSON.stringify(state)) },
user: mock<User>({ id: 'user-id' }), // Different user ID - should be bypassed
});
credentialsRepository.findOneBy.mockResolvedValue(mockCredential);
jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);
jest.spyOn(service as any, 'verifyCsrfState').mockReturnValue(true);
const result = await service.resolveCredential(req);
// Should succeed despite different user ID because origin is dynamic-credential
expect(result).toEqual([mockCredential, mockDecryptedData, mockOAuthCredentials, state]);
// CSRF validation should still be called
expect((service as any).verifyCsrfState).toHaveBeenCalledWith(mockDecryptedData, state);
});
it('should still verify CSRF for dynamic credentials even when req.user is undefined', async () => {
const state = {
token: 'token',
cid: 'credential-id',
userId: 'user-id',
origin: 'dynamic-credential' as const,
createdAt: timestamp,
};
const mockCredential = mock<CredentialsEntity>({ id: 'credential-id' });
const mockDecryptedData = { csrfSecret: 'csrf-secret' };
const mockOAuthCredentials = { clientId: 'client-id' };
const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();
const token = new (require('csrf'))();
const stateToken = token.create('csrf-secret');
state.token = stateToken;
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { state: cipher.encrypt(JSON.stringify(state)) },
user: undefined, // No user - should be bypassed for dynamic credentials
});
credentialsRepository.findOneBy.mockResolvedValue(mockCredential);
jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData);
credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData);
credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials);
jest.spyOn(service as any, 'verifyCsrfState').mockReturnValue(true);
const result = await service.resolveCredential(req);
// Should succeed despite no user because origin is dynamic-credential
expect(result).toEqual([mockCredential, mockDecryptedData, mockOAuthCredentials, state]);
// CSRF validation should still be called
expect((service as any).verifyCsrfState).toHaveBeenCalledWith(mockDecryptedData, state);
});
});
describe('saveDynamicCredential', () => {
beforeEach(() => {
// Mock Credentials.getData to return empty object to avoid decryption issues
jest.spyOn(Credentials.prototype, 'getData').mockReturnValue({});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should save dynamic credential with correct parameters', async () => {
const credential = mock<CredentialsEntity>({
id: 'credential-id',
name: 'Test Credential',
type: 'googleOAuth2Api',
data: 'encrypted-data',
});
const oauthTokenData = {
access_token: 'access-token',
refresh_token: 'refresh-token',
};
const authToken = 'token123'; // Controller splits 'Bearer token123' and passes just 'token123'
const credentialResolverId = 'resolver-id';
dynamicCredentialsProxy.storeIfNeeded.mockResolvedValue(undefined);
await service.saveDynamicCredential(
credential,
oauthTokenData,
authToken,
credentialResolverId,
);
expect(dynamicCredentialsProxy.storeIfNeeded).toHaveBeenCalledWith(
{
id: 'credential-id',
name: 'Test Credential',
type: 'googleOAuth2Api',
isResolvable: true,
},
oauthTokenData,
{ version: 1, identity: authToken },
expect.any(Object),
{ credentialResolverId: 'resolver-id' },
);
});
it('should remove csrfSecret from credential data', async () => {
const credential = mock<CredentialsEntity>({
id: 'credential-id',
name: 'Test Credential',
type: 'googleOAuth2Api',
data: 'encrypted-data',
});
const oauthTokenData = {
access_token: 'access-token',
csrfSecret: 'csrf-secret',
};
const authToken = 'token123'; // Controller splits 'Bearer token123' and passes just 'token123'
const credentialResolverId = 'resolver-id';
dynamicCredentialsProxy.storeIfNeeded.mockResolvedValue(undefined);
await service.saveDynamicCredential(
credential,
oauthTokenData,
authToken,
credentialResolverId,
);
// Verify that storeIfNeeded was called with data that doesn't include csrfSecret
const callArgs = dynamicCredentialsProxy.storeIfNeeded.mock.calls[0];
const staticData = callArgs[3] as any;
expect(staticData).not.toHaveProperty('csrfSecret');
});
it('should handle errors from dynamicCredentialsProxy', async () => {
const credential = mock<CredentialsEntity>({
id: 'credential-id',
name: 'Test Credential',
type: 'googleOAuth2Api',
data: 'encrypted-data',
});
const oauthTokenData = {
access_token: 'access-token',
};
const authToken = 'token123'; // Controller splits 'Bearer token123' and passes just 'token123'
const credentialResolverId = 'resolver-id';
const error = new Error('Storage failed');
dynamicCredentialsProxy.storeIfNeeded.mockRejectedValue(error);
await expect(
service.saveDynamicCredential(credential, oauthTokenData, authToken, credentialResolverId),
).rejects.toThrow('Storage failed');
});
});
describe('renderCallbackError', () => {
@@ -741,6 +1072,7 @@ describe('OauthService', () => {
const authUri = await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
});
@@ -791,6 +1123,7 @@ describe('OauthService', () => {
const authUri = await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
});
@@ -835,6 +1168,7 @@ describe('OauthService', () => {
const authUri = await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
});
@@ -888,6 +1222,7 @@ describe('OauthService', () => {
const authUri = await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
});
@@ -936,12 +1271,14 @@ describe('OauthService', () => {
await expect(
service.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
}),
).rejects.toThrow(BadRequestError);
await expect(
service.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
}),
).rejects.toThrow('Invalid OAuth2 server metadata');
@@ -977,12 +1314,14 @@ describe('OauthService', () => {
await expect(
service.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
}),
).rejects.toThrow(BadRequestError);
await expect(
service.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
}),
).rejects.toThrow('Invalid client registration response');
@@ -1033,6 +1372,7 @@ describe('OauthService', () => {
const authUri = await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
});
@@ -1078,6 +1418,7 @@ describe('OauthService', () => {
await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
});
@@ -1111,6 +1452,7 @@ describe('OauthService', () => {
const authUri = await service.generateAOauth1AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
});
@@ -1143,6 +1485,7 @@ describe('OauthService', () => {
const authUri = await service.generateAOauth1AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
});
@@ -1168,6 +1511,7 @@ describe('OauthService', () => {
await expect(
service.generateAOauth1AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
}),
).rejects.toThrow('Request token failed');
+48 -6
View File
@@ -5,7 +5,7 @@ import { CredentialsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import Csrf from 'csrf';
import type { Response } from 'express';
import { Credentials } from 'n8n-core';
import { Credentials, Cipher } from 'n8n-core';
import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { jsonParse, UnexpectedError } from 'n8n-workflow';
@@ -49,6 +49,8 @@ import {
type CsrfState,
type OAuth1CredentialData,
} from './types';
import { CredentialStoreMetadata } from '@/credentials/dynamic-credential-storage.interface';
import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy';
export function shouldSkipAuthOnOAuthCallback() {
const value = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK?.toLowerCase() ?? 'false';
@@ -69,6 +71,8 @@ export class OauthService {
private readonly urlService: UrlService,
private readonly globalConfig: GlobalConfig,
private readonly externalHooks: ExternalHooks,
private readonly cipher: Cipher,
private readonly dynamicCredentialsProxy: DynamicCredentialsProxy,
) {}
getBaseUrl(oauthVersion: OauthVersion) {
@@ -171,7 +175,9 @@ export class OauthService {
}
/** Get a credential without user check */
protected async getCredentialWithoutUser(credentialId: string): Promise<ICredentialsDb | null> {
protected async getCredentialWithoutUser(
credentialId: string,
): Promise<CredentialsEntity | null> {
return await this.credentialsRepository.findOneBy({ id: credentialId });
}
@@ -183,12 +189,14 @@ export class OauthService {
createdAt: Date.now(),
...data,
};
return [csrfSecret, Buffer.from(JSON.stringify(state)).toString('base64')];
const encryptedState = this.cipher.encrypt(JSON.stringify(state));
return [csrfSecret, encryptedState];
}
protected decodeCsrfState(encodedState: string, req: AuthenticatedRequest): CsrfState {
const errorMessage = 'Invalid state format';
const decoded = jsonParse<CsrfState>(Buffer.from(encodedState, 'base64').toString(), {
const decryptedState = this.cipher.decrypt(encodedState);
const decoded = jsonParse<CsrfState>(decryptedState, {
errorMessage,
});
@@ -196,6 +204,11 @@ export class OauthService {
throw new UnexpectedError(errorMessage);
}
// user validation not required for dynamic credentials
if (decoded.origin === 'dynamic-credential') {
return decoded;
}
if (decoded.userId !== req.user?.id) {
throw new AuthError('Unauthorized');
}
@@ -218,7 +231,7 @@ export class OauthService {
async resolveCredential<T>(
req: OAuthRequest.OAuth1Credential.Callback | OAuthRequest.OAuth2Credential.Callback,
): Promise<[ICredentialsDb, ICredentialDataDecryptedObject, T]> {
): Promise<[CredentialsEntity, ICredentialDataDecryptedObject, T, CsrfState]> {
const { state: encodedState } = req.query;
const state = this.decodeCsrfState(encodedState, req);
const credential = await this.getCredentialWithoutUser(state.cid);
@@ -242,7 +255,7 @@ export class OauthService {
throw new UnexpectedError('The OAuth callback state is invalid!');
}
return [credential, decryptedDataOriginal, oauthCredentials];
return [credential, decryptedDataOriginal, oauthCredentials, state];
}
renderCallbackError(res: Response, message: string, reason?: string) {
@@ -555,4 +568,33 @@ export class OauthService {
token_endpoint_auth_method: tokenEndpointAuthMethod,
};
}
async saveDynamicCredential(
credential: CredentialsEntity,
oauthTokenData: ICredentialDataDecryptedObject,
authHeader: string,
credentialResolverId: string,
) {
const credentials = new Credentials(credential, credential.type, credential.data);
credentials.updateData(oauthTokenData, ['csrfSecret']);
const credentialStoreMetadata: CredentialStoreMetadata = {
id: credential.id,
name: credential.name,
type: credential.type,
isResolvable: true,
};
await this.dynamicCredentialsProxy.storeIfNeeded(
{
...credentialStoreMetadata,
isResolvable: true,
},
oauthTokenData,
// todo parse this
{ version: 1, identity: authHeader },
credentials.getData(),
{ credentialResolverId },
);
}
}
+1
View File
@@ -9,6 +9,7 @@ export type CsrfStateRequired = {
export type CreateCsrfStateData = {
cid: string;
origin: 'static-credential' | 'dynamic-credential';
[key: string]: unknown;
};