mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(Snowflake Node): Add OAuth2 credential support (#29391)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
This commit is contained in:
@@ -105,6 +105,7 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [
|
||||
'microsoftOAuth2Api',
|
||||
'highLevelOAuth2Api',
|
||||
'mcpOAuth2Api',
|
||||
'snowflakeOAuth2Api',
|
||||
'facebookGraphApiOAuth2Api',
|
||||
'facebookGraphAppOAuth2Api',
|
||||
'stravaOAuth2Api',
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
const defaultScopes = ['refresh_token', 'session:role:SYSADMIN'];
|
||||
|
||||
export class SnowflakeOAuth2Api implements ICredentialType {
|
||||
name = 'snowflakeOAuth2Api';
|
||||
|
||||
extends = ['oAuth2Api'];
|
||||
|
||||
displayName = 'Snowflake OAuth2 API';
|
||||
|
||||
documentationUrl = 'snowflake';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Account',
|
||||
name: 'account',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description:
|
||||
'Your Snowflake account identifier (e.g. <code>xy12345</code> or <code>xy12345.us-east-1</code>). Used to construct the OAuth2 authorization and token URLs.',
|
||||
},
|
||||
{
|
||||
displayName: 'Database',
|
||||
name: 'database',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Specify the database you want to use after creating the connection',
|
||||
},
|
||||
{
|
||||
displayName: 'Warehouse',
|
||||
name: 'warehouse',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'The default virtual warehouse to use for the session after connecting. Used for performing queries, loading data, etc.',
|
||||
},
|
||||
{
|
||||
displayName: 'Schema',
|
||||
name: 'schema',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Enter the schema you want to use after creating the connection',
|
||||
},
|
||||
{
|
||||
displayName: 'Client Session Keep Alive',
|
||||
name: 'clientSessionKeepAlive',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
"Whether to keep alive the client session. By default, client connections typically time out approximately 3-4 hours after the most recent query was executed. If the parameter clientSessionKeepAlive is set to true, the client's connection to the server will be kept alive indefinitely, even if no queries are executed.",
|
||||
},
|
||||
{
|
||||
displayName: 'Grant Type',
|
||||
name: 'grantType',
|
||||
type: 'hidden',
|
||||
default: 'pkce',
|
||||
},
|
||||
{
|
||||
displayName: 'Authorization URL',
|
||||
name: 'authUrl',
|
||||
type: 'hidden',
|
||||
default: '=https://{{$self["account"]}}.snowflakecomputing.com/oauth/authorize',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Access Token URL',
|
||||
name: 'accessTokenUrl',
|
||||
type: 'hidden',
|
||||
default: '=https://{{$self["account"]}}.snowflakecomputing.com/oauth/token-request',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Auth URI Query Parameters',
|
||||
name: 'authQueryParameters',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'hidden',
|
||||
default: 'header',
|
||||
},
|
||||
{
|
||||
displayName: 'Custom Scopes',
|
||||
name: 'customScopes',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Define custom scopes',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'The default scopes needed for the node to work are already set. If you change these the node may not function correctly.',
|
||||
name: 'customScopesNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
customScopes: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Enabled Scopes',
|
||||
name: 'enabledScopes',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
customScopes: [true],
|
||||
},
|
||||
},
|
||||
default: defaultScopes.join(' '),
|
||||
description: 'Scopes that should be enabled',
|
||||
},
|
||||
{
|
||||
displayName: 'Scope',
|
||||
name: 'scope',
|
||||
type: 'hidden',
|
||||
default:
|
||||
'={{$self["customScopes"] ? $self["enabledScopes"] : "' + defaultScopes.join(' ') + '"}}',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||
import nock from 'nock';
|
||||
|
||||
import { SnowflakeOAuth2Api } from '../SnowflakeOAuth2Api.credentials';
|
||||
|
||||
describe('SnowflakeOAuth2Api Credential', () => {
|
||||
const snowflakeOAuth2Api = new SnowflakeOAuth2Api();
|
||||
const defaultScopes = ['refresh_token', 'session:role:SYSADMIN'];
|
||||
|
||||
const account = 'xy12345.us-east-1';
|
||||
const baseUrl = `https://${account}.snowflakecomputing.com`;
|
||||
const authorizationUri = `${baseUrl}/oauth/authorize`;
|
||||
const accessTokenUri = `${baseUrl}/oauth/token-request`;
|
||||
const redirectUri = 'http://localhost:5678/rest/oauth2-credential/callback';
|
||||
const clientId = 'test-client-id';
|
||||
const clientSecret = 'test-client-secret';
|
||||
|
||||
const createOAuthClient = (scopes: string[]) =>
|
||||
new ClientOAuth2({
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessTokenUri,
|
||||
authorizationUri,
|
||||
redirectUri,
|
||||
scopes,
|
||||
});
|
||||
|
||||
const mockTokenEndpoint = (code: string, responseScopes: string[]) => {
|
||||
nock(baseUrl)
|
||||
.post('/oauth/token-request', (body: Record<string, unknown>) => {
|
||||
return (
|
||||
body.code === code &&
|
||||
body.grant_type === 'authorization_code' &&
|
||||
body.redirect_uri === redirectUri
|
||||
);
|
||||
})
|
||||
.reply(200, {
|
||||
access_token: 'test-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: responseScopes.join(' '),
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should have correct credential metadata', () => {
|
||||
expect(snowflakeOAuth2Api.name).toBe('snowflakeOAuth2Api');
|
||||
expect(snowflakeOAuth2Api.extends).toEqual(['oAuth2Api']);
|
||||
|
||||
const authUrlProperty = snowflakeOAuth2Api.properties.find((p) => p.name === 'authUrl');
|
||||
expect(authUrlProperty?.default).toContain('snowflakecomputing.com/oauth/authorize');
|
||||
|
||||
const accessTokenUrlProperty = snowflakeOAuth2Api.properties.find(
|
||||
(p) => p.name === 'accessTokenUrl',
|
||||
);
|
||||
expect(accessTokenUrlProperty?.default).toContain('snowflakecomputing.com/oauth/token-request');
|
||||
|
||||
const enabledScopesProperty = snowflakeOAuth2Api.properties.find(
|
||||
(p) => p.name === 'enabledScopes',
|
||||
);
|
||||
expect(enabledScopesProperty?.default).toBe('refresh_token session:role:SYSADMIN');
|
||||
});
|
||||
|
||||
describe('OAuth2 flow with default scopes', () => {
|
||||
it('should include default scopes in authorization URI', () => {
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
expect(authUri).toContain('scope=');
|
||||
expect(authUri).toContain('refresh_token');
|
||||
expect(authUri).toContain('session%3Arole%3ASYSADMIN');
|
||||
expect(authUri).toContain(`client_id=${clientId}`);
|
||||
expect(authUri).toContain('response_type=code');
|
||||
});
|
||||
|
||||
it('should retrieve token successfully with default scopes', async () => {
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, defaultScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toContain('refresh_token');
|
||||
expect(token.data.scope).toContain('session:role:SYSADMIN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth2 flow with custom scopes', () => {
|
||||
const customScopes = ['session:role:ANALYST', 'session:role:SYSADMIN'];
|
||||
|
||||
it('should include custom scopes in authorization URI', () => {
|
||||
const oauthClient = createOAuthClient(customScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
expect(authUri).toContain('scope=');
|
||||
expect(authUri).toContain('ANALYST');
|
||||
expect(authUri).toContain('SYSADMIN');
|
||||
});
|
||||
|
||||
it('should retrieve token successfully with custom scopes', async () => {
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, customScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(customScopes);
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toContain('session:role:ANALYST');
|
||||
expect(token.data.scope).toContain('session:role:SYSADMIN');
|
||||
});
|
||||
|
||||
it('should handle a minimal scope set and exclude default scopes', async () => {
|
||||
const minimalScopes = ['session:role:READONLY'];
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, minimalScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(minimalScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
expect(authUri).toContain('READONLY');
|
||||
expect(authUri).not.toContain('role%3Aall');
|
||||
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
expect(token.data.scope).toContain('session:role:READONLY');
|
||||
expect(token.data.scope).not.toContain('session:role:all');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,10 @@ export type SnowflakeCredential = Pick<
|
||||
privateKey: string;
|
||||
passphrase?: string;
|
||||
}
|
||||
| {
|
||||
authentication: 'oauth2';
|
||||
token: string;
|
||||
}
|
||||
);
|
||||
|
||||
const extractPrivateKey = (credential: { privateKey: string; passphrase?: string }) => {
|
||||
@@ -54,6 +58,9 @@ export const getConnectionOptions = (credential: SnowflakeCredential) => {
|
||||
connectionOptions.authenticator = 'SNOWFLAKE_JWT';
|
||||
connectionOptions.username = credential.username;
|
||||
connectionOptions.privateKey = extractPrivateKey(credential);
|
||||
} else if (credential.authentication === 'oauth2') {
|
||||
connectionOptions.authenticator = 'OAUTH';
|
||||
connectionOptions.token = credential.token;
|
||||
} else {
|
||||
connectionOptions.username = credential.username;
|
||||
connectionOptions.password = credential.password;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
import snowflake from 'snowflake-sdk';
|
||||
|
||||
import { getResolvables } from '@utils/utilities';
|
||||
@@ -39,9 +39,39 @@ export class Snowflake implements INodeType {
|
||||
{
|
||||
name: 'snowflake',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['credentials'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'snowflakeOAuth2Api',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['oAuth2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Credentials',
|
||||
value: 'credentials',
|
||||
},
|
||||
{
|
||||
name: 'OAuth2',
|
||||
value: 'oAuth2',
|
||||
},
|
||||
],
|
||||
default: 'credentials',
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
@@ -172,14 +202,38 @@ export class Snowflake implements INodeType {
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const credentials = await this.getCredentials<SnowflakeCredential>('snowflake');
|
||||
// Disable logging - https://docs.snowflake.com/en/developer-guide/node-js/nodejs-driver-logs#configure-the-default-logging-behavior
|
||||
snowflake.configure({
|
||||
logFilePath: 'STDOUT',
|
||||
logLevel: 'OFF',
|
||||
});
|
||||
|
||||
const connectionOptions = getConnectionOptions(credentials);
|
||||
const authMethod = this.getNodeParameter('authentication', 0, 'credentials') as string;
|
||||
let snowflakeCredential: SnowflakeCredential;
|
||||
|
||||
if (authMethod === 'oAuth2') {
|
||||
const oauthCredentials = await this.getCredentials('snowflakeOAuth2Api');
|
||||
const tokenData = oauthCredentials.oauthTokenData as { access_token?: string } | undefined;
|
||||
if (!tokenData?.access_token) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'OAuth2 access token is missing. Please reconnect your Snowflake OAuth2 credential.',
|
||||
);
|
||||
}
|
||||
snowflakeCredential = {
|
||||
account: oauthCredentials.account as string,
|
||||
database: oauthCredentials.database as string,
|
||||
warehouse: oauthCredentials.warehouse as string,
|
||||
schema: oauthCredentials.schema as string,
|
||||
clientSessionKeepAlive: oauthCredentials.clientSessionKeepAlive as boolean,
|
||||
authentication: 'oauth2',
|
||||
token: tokenData.access_token,
|
||||
};
|
||||
} else {
|
||||
snowflakeCredential = await this.getCredentials<SnowflakeCredential>('snowflake');
|
||||
}
|
||||
|
||||
const connectionOptions = getConnectionOptions(snowflakeCredential);
|
||||
const connection = snowflake.createConnection(connectionOptions);
|
||||
|
||||
await connect(connection);
|
||||
|
||||
@@ -70,6 +70,20 @@ describe('getConnectionOptions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('with oauth token for oauth2 authentication', () => {
|
||||
const result = getConnectionOptions({
|
||||
...commonOptions,
|
||||
authentication: 'oauth2',
|
||||
token: 'test-oauth-token',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...commonOptions,
|
||||
authenticator: 'OAUTH',
|
||||
token: 'test-oauth-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('with private key for keyPair authentication and passphrase', () => {
|
||||
const createPrivateKeySpy = jest.spyOn(crypto, 'createPrivateKey').mockImplementation(
|
||||
() =>
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"dist/credentials/Sms77Api.credentials.js",
|
||||
"dist/credentials/Smtp.credentials.js",
|
||||
"dist/credentials/Snowflake.credentials.js",
|
||||
"dist/credentials/SnowflakeOAuth2Api.credentials.js",
|
||||
"dist/credentials/SolarWindsIpamApi.credentials.js",
|
||||
"dist/credentials/SolarWindsObservabilityApi.credentials.js",
|
||||
"dist/credentials/SplunkApi.credentials.js",
|
||||
|
||||
Reference in New Issue
Block a user