n8n/packages/cli/test/integration/controllers/oauth/oauth2.skip-auth.api.test.ts

274 lines
9.1 KiB
TypeScript

/**
* Integration tests for OAuth2 callback with N8N_SKIP_AUTH_ON_OAUTH_CALLBACK=true
*
* IMPORTANT: Environment variable must be set before module imports
* because skipAuthOnOAuthCallback is evaluated at module load time.
*/
// Set environment variable before any imports
process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'true';
import { testDb } from '@n8n/backend-test-utils';
import type { CredentialsEntity, User } from '@n8n/db';
import { Container } from '@n8n/di';
import { response as Response } from 'express';
import nock from 'nock';
import { parse as parseQs } from 'querystring';
import { CredentialsHelper } from '@/credentials-helper';
import { OauthService } from '@/oauth/oauth.service';
import { saveCredential } from '@test-integration/db/credentials';
import { createMember, createOwner } from '@test-integration/db/users';
import type { SuperAgentTest } from '@test-integration/types';
import { setupTestServer } from '@test-integration/utils';
describe('OAuth2 API with skipAuthOnOAuthCallback enabled', () => {
const testServer = setupTestServer({ endpointGroups: ['oauth2'] });
let owner: User;
let anotherUser: User;
let ownerAgent: SuperAgentTest;
let credential: CredentialsEntity;
const credentialData = {
clientId: 'client_id',
clientSecret: 'client_secret',
authUrl: 'https://test.domain/oauth2/auth',
accessTokenUrl: 'https://test.domain/oauth2/token',
authQueryParameters: 'access_type=offline',
};
CredentialsHelper.prototype.applyDefaultsAndOverwrites = async (_, decryptedDataOriginal) =>
decryptedDataOriginal;
beforeAll(async () => {
owner = await createOwner();
anotherUser = await createMember();
ownerAgent = testServer.authAgentFor(owner);
});
beforeEach(async () => {
await testDb.truncate(['SharedCredentials', 'CredentialsEntity']);
credential = await saveCredential(
{
name: 'Test',
type: 'testOAuth2Api',
data: credentialData,
},
{
user: owner,
role: 'credential:owner',
},
);
});
afterEach(() => {
nock.cleanAll();
});
afterAll(async () => {
// Clean up environment variable
delete process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK;
});
describe('OAuth callback without authentication', () => {
it('should handle OAuth callback without authentication when skipAuthOnOAuthCallback is enabled', async () => {
const oauthService = Container.get(OauthService);
const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
const renderSpy = (Response.render = jest.fn(function () {
this.end();
}));
// Step 1: Owner initiates OAuth flow (authenticated)
await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);
const [_, state] = await csrfSpy.mock.results[0].value;
// Step 2: Mock external OAuth provider response
nock('https://test.domain')
.post('/oauth2/token')
.reply(200, { access_token: 'new_access_token' });
// Step 3: Callback arrives WITHOUT authentication
// This simulates the real-world scenario where skipAuth: true is configured
// and the OAuth provider redirects back without going through auth middleware
await testServer.authlessAgent
.get('/oauth2-credential/callback')
.query({ code: 'auth_code', state })
.expect(200);
// Verify success - should NOT render error page
expect(renderSpy).toHaveBeenCalledWith('oauth-callback');
expect(renderSpy).not.toHaveBeenCalledWith('oauth-error-callback', expect.anything());
// Verify credential was updated with OAuth token
const updatedCredential = await Container.get(CredentialsHelper).getCredentials(
credential,
credential.type,
);
expect(await updatedCredential.getData()).toEqual({
...credentialData,
oauthTokenData: { access_token: 'new_access_token' },
});
});
it('should allow callback completion by any user when skipAuthOnOAuthCallback is enabled', async () => {
const oauthService = Container.get(OauthService);
const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
const renderSpy = (Response.render = jest.fn(function () {
this.end();
}));
// Step 1: Owner initiates OAuth flow
await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);
const [_, state] = await csrfSpy.mock.results[0].value;
// Step 2: Mock external OAuth provider response
nock('https://test.domain')
.post('/oauth2/token')
.reply(200, { access_token: 'different_user_token' });
// Step 3: Different user completes the callback
// When skipAuth is enabled, userId validation is skipped
// This is intentional for scenarios where auth middleware cannot run
await testServer
.authAgentFor(anotherUser)
.get('/oauth2-credential/callback')
.query({ code: 'auth_code', state })
.expect(200);
// Should succeed without error
expect(renderSpy).toHaveBeenCalledWith('oauth-callback');
expect(renderSpy).not.toHaveBeenCalledWith('oauth-error-callback', expect.anything());
// Verify credential was updated
const updatedCredential = await Container.get(CredentialsHelper).getCredentials(
credential,
credential.type,
);
expect(await updatedCredential.getData()).toEqual({
...credentialData,
oauthTokenData: { access_token: 'different_user_token' },
});
});
});
describe('OAuth flow initiation', () => {
it('should return a valid auth URL when the auth flow is initiated', async () => {
const response = await ownerAgent
.get('/oauth2-credential/auth')
.query({ id: credential.id })
.expect(200);
const authUrl = new URL(response.body.data);
expect(authUrl.hostname).toBe('test.domain');
expect(authUrl.pathname).toBe('/oauth2/auth');
const queryParams = parseQs(authUrl.search.slice(1));
expect(queryParams).toMatchObject({
access_type: 'offline',
client_id: 'client_id',
redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback',
response_type: 'code',
scope: 'openid',
});
// Verify state is base64-encoded and contains expected structure
expect(queryParams.state).toBeDefined();
const decodedState = JSON.parse(
Buffer.from(queryParams.state as string, 'base64').toString(),
);
expect(decodedState).toMatchObject({
token: expect.any(String),
createdAt: expect.any(Number),
data: expect.any(String), // Encrypted CSRF data
});
});
});
describe('Error handling', () => {
it('should still validate CSRF state even when skipAuthOnOAuthCallback is enabled', async () => {
const renderSpy = (Response.render = jest.fn(function () {
this.end();
}));
// Attempt callback with invalid state
await testServer.authlessAgent
.get('/oauth2-credential/callback')
.query({ code: 'auth_code', state: 'invalid_state' })
.expect(200);
// Should render error due to invalid CSRF state
expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', {
error: expect.objectContaining({
message: expect.any(String),
}),
});
});
it('should handle OAuth provider errors gracefully', async () => {
const oauthService = Container.get(OauthService);
const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
const renderSpy = (Response.render = jest.fn(function () {
this.end();
}));
// Initiate OAuth flow
await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);
const [_, state] = await csrfSpy.mock.results[0].value;
// Mock OAuth provider returning an error
nock('https://test.domain').post('/oauth2/token').reply(400, {
error: 'invalid_grant',
error_description: 'Authorization code has expired',
});
// Callback should handle provider error
await testServer.authlessAgent
.get('/oauth2-credential/callback')
.query({ code: 'expired_code', state })
.expect(200);
// Should render error callback
expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', {
error: expect.objectContaining({
message: expect.any(String),
}),
});
});
});
describe('Security validation', () => {
it('should not skip CSRF token validation when skipAuthOnOAuthCallback is enabled', async () => {
const oauthService = Container.get(OauthService);
const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
const renderSpy = (Response.render = jest.fn(function () {
this.end();
}));
// Initiate OAuth flow to get a valid state
await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);
const [__, state] = await csrfSpy.mock.results[0].value;
// Tamper with the state (decrypt, modify, re-encrypt would be needed)
// For this test, we'll use a completely different valid-looking but wrong state
const tamperedState = state.replace(/[a-z]/, 'x');
// Attempt callback with tampered state
await testServer.authlessAgent
.get('/oauth2-credential/callback')
.query({ code: 'auth_code', state: tamperedState })
.expect(200);
// Should render error due to CSRF validation failure
expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', {
error: expect.objectContaining({
message: expect.any(String),
}),
});
});
});
});