From 70e3e06026aee5e68923082d9725ee3776c55d6a Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 18 May 2026 12:30:47 +0100 Subject: [PATCH] feat(Snowflake Node): Add OAuth2 credential support (#29391) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> --- packages/cli/src/constants.ts | 1 + .../SnowflakeOAuth2Api.credentials.ts | 125 ++++++++++++++++ .../SnowflakeOAuth2Api.credentials.test.ts | 138 ++++++++++++++++++ .../nodes/Snowflake/GenericFunctions.ts | 7 + .../nodes/Snowflake/Snowflake.node.ts | 60 +++++++- .../__tests__/GenericFunctions.test.ts | 14 ++ packages/nodes-base/package.json | 1 + 7 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 packages/nodes-base/credentials/SnowflakeOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/test/SnowflakeOAuth2Api.credentials.test.ts diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index f3860cc69e9..e450b63db7a 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -105,6 +105,7 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [ 'microsoftOAuth2Api', 'highLevelOAuth2Api', 'mcpOAuth2Api', + 'snowflakeOAuth2Api', 'facebookGraphApiOAuth2Api', 'facebookGraphAppOAuth2Api', 'stravaOAuth2Api', diff --git a/packages/nodes-base/credentials/SnowflakeOAuth2Api.credentials.ts b/packages/nodes-base/credentials/SnowflakeOAuth2Api.credentials.ts new file mode 100644 index 00000000000..7a1dd0906ae --- /dev/null +++ b/packages/nodes-base/credentials/SnowflakeOAuth2Api.credentials.ts @@ -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. xy12345 or xy12345.us-east-1). 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(' ') + '"}}', + }, + ]; +} diff --git a/packages/nodes-base/credentials/test/SnowflakeOAuth2Api.credentials.test.ts b/packages/nodes-base/credentials/test/SnowflakeOAuth2Api.credentials.test.ts new file mode 100644 index 00000000000..4e89eae603a --- /dev/null +++ b/packages/nodes-base/credentials/test/SnowflakeOAuth2Api.credentials.test.ts @@ -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) => { + 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'); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Snowflake/GenericFunctions.ts b/packages/nodes-base/nodes/Snowflake/GenericFunctions.ts index 2a09c0fb6d8..fb83328df90 100644 --- a/packages/nodes-base/nodes/Snowflake/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Snowflake/GenericFunctions.ts @@ -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; diff --git a/packages/nodes-base/nodes/Snowflake/Snowflake.node.ts b/packages/nodes-base/nodes/Snowflake/Snowflake.node.ts index aa7c51945e3..9b252f359e8 100644 --- a/packages/nodes-base/nodes/Snowflake/Snowflake.node.ts +++ b/packages/nodes-base/nodes/Snowflake/Snowflake.node.ts @@ -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 { - const credentials = await this.getCredentials('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('snowflake'); + } + + const connectionOptions = getConnectionOptions(snowflakeCredential); const connection = snowflake.createConnection(connectionOptions); await connect(connection); diff --git a/packages/nodes-base/nodes/Snowflake/__tests__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Snowflake/__tests__/GenericFunctions.test.ts index 464542ef9c0..d3d831c7cda 100644 --- a/packages/nodes-base/nodes/Snowflake/__tests__/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Snowflake/__tests__/GenericFunctions.test.ts @@ -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( () => diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 04e1fc8a3dd..dad78618c1e 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -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",