From 3224507957b1218255c4257e99ec2cb1a79454c9 Mon Sep 17 00:00:00 2001 From: edwardhan Date: Tue, 20 Oct 2020 16:51:37 -0700 Subject: [PATCH 1/2] add key pair authentication --- lib/connection/auth_keypair.js | 46 +++++++++++++++++++ lib/connection/connection_config.js | 32 ++++++++++--- lib/constants/error_messages.js | 1 + lib/errors.js | 1 + lib/global_config.js | 6 +++ lib/services/sf.js | 7 +++ lib/util.js | 14 ++++++ package.json | 3 +- test/integration/connectionOptions.js | 30 ++++++++++++ test/integration/testConnection.js | 11 +++++ .../unit/connection/connection_config_test.js | 40 ++++++++++++++-- 11 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 lib/connection/auth_keypair.js diff --git a/lib/connection/auth_keypair.js b/lib/connection/auth_keypair.js new file mode 100644 index 000000000..a03e19b0f --- /dev/null +++ b/lib/connection/auth_keypair.js @@ -0,0 +1,46 @@ +const crypto = require('crypto'); +const jwt = require('jsonwebtoken'); + +const LIFETIME = 120; // seconds +const ALGORITHM = 'RS256' +const ISSUER = 'iss' +const SUBJECT = 'sub' +const EXPIRE_TIME = 'exp' +const ISSUE_TIME = 'iat' + +exports.generateToken = function ({ account, username, privateKey }) { + account = account.toUpperCase(); + username = username.toUpperCase(); + + const privateKeyObj = crypto.createPrivateKey({ + key: privateKey, + format: 'der', + type: 'pkcs8' + }); + + const publicKeyFp = calculatePublicKeyFingerprint(privateKeyObj); + + const now = new Date(); + const jwtExpiration = new Date(now.getTime()); + jwtExpiration.setSeconds(jwtExpiration.getSeconds() + LIFETIME); + + const payload = { + [ISSUER]: `${account}.${username}.${publicKeyFp}`, + [SUBJECT]: `${account}.${username}`, + [ISSUE_TIME]: Math.floor(now.getTime() / 1000), + [EXPIRE_TIME]: Math.floor(jwtExpiration.getTime() / 1000), + } + + return jwt.sign(payload, privateKeyObj, { algorithm: ALGORITHM }); +} + +function calculatePublicKeyFingerprint(privateKeyObj) +{ + const publicKeyObj = crypto.createPublicKey(privateKeyObj); + const publicKeyBytes = publicKeyObj.export({type: 'spki', format: 'der'}); + + const sha256Hash = crypto.createHash('sha256').update(publicKeyBytes).digest('base64'); + const publicKeyFp = `SHA256:${sha256Hash}`; + + return publicKeyFp; +} \ No newline at end of file diff --git a/lib/connection/connection_config.js b/lib/connection/connection_config.js index e882f3836..293be604d 100644 --- a/lib/connection/connection_config.js +++ b/lib/connection/connection_config.js @@ -9,6 +9,7 @@ const Errors = require('../errors'); const ErrorCodes = Errors.codes; const NativeTypes = require('./result/data_types').NativeTypes; const GlobalConfig = require('../global_config'); +const AuthByKeyPair = require('./auth_keypair'); function consolidateHostAndAccount(options) { @@ -110,13 +111,26 @@ function ConnectionConfig(options, validateCredentials, qaMode, clientInfo) Errors.checkArgumentValid(Util.isString(options.username), ErrorCodes.ERR_CONN_CREATE_INVALID_USERNAME); - // check for missing password - Errors.checkArgumentExists(Util.exists(options.password), - ErrorCodes.ERR_CONN_CREATE_MISSING_PASSWORD); + // if using key pair authentication + if (Util.exists(options.privateKey)) { + // check for valid private key + Errors.checkArgumentValid(Util.isPrivateKey(options.privateKey), + ErrorCodes.ERR_CONN_CREATE_INVALID_PRIVATE_KEY); - // check for invalid password - Errors.checkArgumentValid(Util.isString(options.password), - ErrorCodes.ERR_CONN_CREATE_INVALID_PASSWORD); + options.authenticator = GlobalConfig.authenticator.KEY_PAIR; + } + // using default password authentication + else { + // check for missing password + Errors.checkArgumentExists(Util.exists(options.password), + ErrorCodes.ERR_CONN_CREATE_MISSING_PASSWORD); + + // check for invalid password + Errors.checkArgumentValid(Util.isString(options.password), + ErrorCodes.ERR_CONN_CREATE_INVALID_PASSWORD); + + options.authenticator = GlobalConfig.authenticator.DEFAULT; + } consolidateHostAndAccount(options); } @@ -395,9 +409,15 @@ function ConnectionConfig(options, validateCredentials, qaMode, clientInfo) return jsTreatIntegerAsBigInt; }; + this.getKeyPairToken = function () + { + return AuthByKeyPair.generateToken(options); + }; + // save config options this.username = options.username; this.password = options.password; + this.authenticator = options.authenticator; this.accessUrl = options.accessUrl; this.account = options.account; this.sessionToken = options.sessionToken; diff --git a/lib/constants/error_messages.js b/lib/constants/error_messages.js index 260b71f0b..5d580cf62 100644 --- a/lib/constants/error_messages.js +++ b/lib/constants/error_messages.js @@ -47,6 +47,7 @@ exports[404022] = 'Invalid region. The specified value must be a string.'; exports[404023] = 'Invalid clientSessionKeepAlive. The specified value must be a boolean.'; exports[404024] = 'Invalid clientSessionKeepAliveHeartbeatFrequency. The specified value must be a number.'; exports[404025] = 'Invalid jsTreatIntegerAsBigInt. The specified value must be a boolean'; +exports[404026] = 'Invalid private key. Please provide a valid unencrypted rsa private key in DER format with type pkcs8 as a buffer'; // 405001 exports[405001] = 'Invalid callback. The specified value must be a function.'; diff --git a/lib/errors.js b/lib/errors.js index ea5617eeb..9f8fb5242 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -52,6 +52,7 @@ codes.ERR_CONN_CREATE_INVALID_REGION = 404022; codes.ERR_CONN_CREATE_INVALID_KEEP_ALIVE = 404023; codes.ERR_CONN_CREATE_INVALID_KEEP_ALIVE_HEARTBEAT_FREQ = 404024; codes.ERR_CONN_CREATE_INVALID_TREAT_INTEGER_AS_BIGINT = 404025; +codes.ERR_CONN_CREATE_INVALID_PRIVATE_KEY = 404026; // 405001 codes.ERR_CONN_CONNECT_INVALID_CALLBACK = 405001; diff --git a/lib/global_config.js b/lib/global_config.js index 1fa92428f..5e37f3faa 100644 --- a/lib/global_config.js +++ b/lib/global_config.js @@ -139,3 +139,9 @@ exports.mkdirCacheDir = function () } return cacheDir; }; + +const authenticator = { + DEFAULT: 'SNOWFLAKE', + KEY_PAIR: 'SNOWFLAKE_JWT', +} +exports.authenticator = authenticator; \ No newline at end of file diff --git a/lib/services/sf.js b/lib/services/sf.js index ea519134a..7392ae8d6 100644 --- a/lib/services/sf.js +++ b/lib/services/sf.js @@ -53,6 +53,7 @@ const Url = require('url'); const QueryString = require('querystring'); const Parameters = require('../parameters'); const GSErrors = require('../constants/gs_errors') +const GlobalConfig = require('../global_config'); const Logger = require('../logger'); @@ -1021,6 +1022,12 @@ StateConnecting.prototype.continue = function (options) PASSWORD: this.connectionConfig.password } }; + + if (this.connectionConfig.authenticator === GlobalConfig.authenticator.KEY_PAIR) + { + json.data.AUTHENTICATOR = this.connectionConfig.authenticator; + json.data.TOKEN = this.connectionConfig.getKeyPairToken(); + } } // extract the inflight context from the error and put it back in the json diff --git a/lib/util.js b/lib/util.js index 28724e401..55bda5db3 100644 --- a/lib/util.js +++ b/lib/util.js @@ -5,6 +5,7 @@ var util = require('util'); var Url = require('url'); +var crypto = require('crypto'); /** * Note: A simple wrapper around util.inherits() for now, but this might change @@ -443,3 +444,16 @@ const userAgent = 'JavaScript' + '/' + driverVersion + ' (' + process.platform + '-' + process.arch + ') ' + 'NodeJS' + '/' + nodeJSVersion; exports.userAgent = userAgent; + +exports.isPrivateKey = function (privateKey) { + try { + return !!(crypto.createPrivateKey({ + key: privateKey, + format: 'der', + type: 'pkcs8' + })); + } + catch (err) { + return false; + } +} \ No newline at end of file diff --git a/package.json b/package.json index 310377071..4ce1c286d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "debug": "^3.2.6", "extend": "^3.0.2", "https-proxy-agent": "^3.0.0", + "jsonwebtoken": "^8.5.1", + "lodash": "^4.17.15", "mkdirp": "^1.0.3", "moment": "^2.23.0", "moment-timezone": "^0.5.15", @@ -20,7 +22,6 @@ "requestretry": "^4.1.0", "simple-lru-cache": "^0.0.2", "uuid": "^3.3.2", - "lodash": "^4.17.15", "winston": "^3.1.0" }, "devDependencies": { diff --git a/test/integration/connectionOptions.js b/test/integration/connectionOptions.js index ee264488c..dc1ad941a 100644 --- a/test/integration/connectionOptions.js +++ b/test/integration/connectionOptions.js @@ -1,6 +1,9 @@ /* * Copyright (c) 2015-2019 Snowflake Computing Inc. All rights reserved. */ +const fs = require('fs'); +const crypto = require('crypto'); + let snowflakeTestProtocol = process.env.SNOWFLAKE_TEST_PROTOCOL; let snowflakeTestHost = process.env.SNOWFLAKE_TEST_HOST; let snowflakeTestPort = process.env.SNOWFLAKE_TEST_PORT; @@ -15,6 +18,9 @@ const snowflakeTestRole = process.env.SNOWFLAKE_TEST_ROLE; const snowflakeTestPassword = process.env.SNOWFLAKE_TEST_PASSWORD; const snowflakeTestAdminUser = process.env.SNOWFLAKE_TEST_ADMIN_USER; const snowflakeTestAdminPassword = process.env.SNOWFLAKE_TEST_ADMIN_PASSWORD; +const snowflakeTestPrivateKeyUser = process.env.SNOWFLAKE_TEST_PRIVATE_KEY_USER; +const snowflakeTestPrivateKeyPath = process.env.SNOWFLAKE_TEST_PRIVATE_KEY_PATH; +const snowflakeTestPrivateKeyPassphrase = process.env.SNOWFLAKE_TEST_PRIVATE_KEY_PASSPHRASE; if (snowflakeTestProtocol === undefined) { @@ -82,6 +88,30 @@ var wrongPwd = account: snowflakeTestAccount }; +var getPrivateKey = function () { + try { + const privateKey = fs.readFileSync(snowflakeTestPrivateKeyPath, 'utf-8'); + const privateKeyPEM = crypto.createPrivateKey({ + key: privateKey, + format: 'pem', + passphrase: snowflakeTestPrivateKeyPassphrase, + }); + + return privateKeyPEM.export({ type: 'pkcs8', format: 'der' }); + } + catch (err) { + return 'invalid_private_key'; + } +}; +var validKeyPairAuth = + { + accessUrl: accessUrl, + username: snowflakeTestPrivateKeyUser, + account: snowflakeTestAccount, + privateKey: getPrivateKey(), + }; + +exports.validKeyPairAuth = validKeyPairAuth; exports.valid = valid; exports.snowflakeAccount = snowflakeAccount; exports.wrongUserName = wrongUserName; diff --git a/test/integration/testConnection.js b/test/integration/testConnection.js index 05dc45930..250ce5dac 100644 --- a/test/integration/testConnection.js +++ b/test/integration/testConnection.js @@ -77,6 +77,16 @@ describe('Connection test', function () ); }); + it('Connect using Key Pair', function (done) + { + var connection = snowflake.createConnection(connOption.validKeyPairAuth); + connection.connect(function (err) + { + assert.ok(!err, JSON.stringify(err)); + done(); + }); + }); + it('Wrong Username', function (done) { var connection = snowflake.createConnection(connOption.wrongUserName); @@ -98,6 +108,7 @@ describe('Connection test', function () done(); }); }); + it('Multiple Client', function (done) { const totalConnections = 10; diff --git a/test/unit/connection/connection_config_test.js b/test/unit/connection/connection_config_test.js index 35b4e6196..68b4f0449 100644 --- a/test/unit/connection/connection_config_test.js +++ b/test/unit/connection/connection_config_test.js @@ -2,9 +2,11 @@ * Copyright (c) 2015 Snowflake Computing Inc. All rights reserved. */ -var ConnectionConfig = require('./../../../lib/connection/connection_config'); -var ErrorCodes = require('./../../../lib/errors').codes; -var assert = require('assert'); +const ConnectionConfig = require('./../../../lib/connection/connection_config'); +const ErrorCodes = require('./../../../lib/errors').codes; +const assert = require('assert'); +const crypto = require('crypto'); +const GlobalConfig = require('../../../lib/global_config'); describe('ConnectionConfig: basic', function () { @@ -253,6 +255,16 @@ describe('ConnectionConfig: basic', function () fetchAsString: ['invalid'] }, errorCode: ErrorCodes.ERR_CONN_CREATE_INVALID_FETCH_AS_STRING_VALUES + }, + { + name: 'invalid privateKey', + options: + { + username: 'username', + privateKey: 'invalid', + account: 'account', + }, + errorCode: ErrorCodes.ERR_CONN_CREATE_INVALID_PRIVATE_KEY } ]; @@ -508,4 +520,26 @@ describe('ConnectionConfig: basic', function () assert.strictEqual( connectionConfig.getResultPrefetch(), resultPrefetchCustom); }); + + it('sets authenticator to key pair if privateKey exists', function () { + const keyPair = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { + type: 'pkcs8', + format: 'der' + } + }); + const privateKey = keyPair.privateKey; + + const connOption = + { + username: 'username', + account: 'account', + privateKey: privateKey, + }; + + const result = new ConnectionConfig(connOption); + + assert.strictEqual(result.authenticator, GlobalConfig.authenticator.KEY_PAIR); + }); }); \ No newline at end of file From 851f7c2e596415d64f7582bcc49bcd6e5a548b3b Mon Sep 17 00:00:00 2001 From: edwardhan Date: Wed, 21 Oct 2020 13:55:15 -0700 Subject: [PATCH 2/2] set default private key user --- test/integration/connectionOptions.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/integration/connectionOptions.js b/test/integration/connectionOptions.js index dc1ad941a..f5f94c4f3 100644 --- a/test/integration/connectionOptions.js +++ b/test/integration/connectionOptions.js @@ -18,7 +18,7 @@ const snowflakeTestRole = process.env.SNOWFLAKE_TEST_ROLE; const snowflakeTestPassword = process.env.SNOWFLAKE_TEST_PASSWORD; const snowflakeTestAdminUser = process.env.SNOWFLAKE_TEST_ADMIN_USER; const snowflakeTestAdminPassword = process.env.SNOWFLAKE_TEST_ADMIN_PASSWORD; -const snowflakeTestPrivateKeyUser = process.env.SNOWFLAKE_TEST_PRIVATE_KEY_USER; +let snowflakeTestPrivateKeyUser = process.env.SNOWFLAKE_TEST_PRIVATE_KEY_USER; const snowflakeTestPrivateKeyPath = process.env.SNOWFLAKE_TEST_PRIVATE_KEY_PATH; const snowflakeTestPrivateKeyPassphrase = process.env.SNOWFLAKE_TEST_PRIVATE_KEY_PASSPHRASE; @@ -47,6 +47,11 @@ if (snowflakeTestProxyPort === undefined) snowflakeTestProxyPort = '3128'; } +if (snowflakeTestPrivateKeyUser === undefined) +{ + snowflakeTestPrivateKeyUser = snowflakeTestUser; +} + const accessUrl = snowflakeTestProtocol + '://' + snowflakeTestHost + ':' + snowflakeTestPort;