From 8f0b0f65e1db163432e4b6de9e2a431041e7f95e Mon Sep 17 00:00:00 2001 From: John Yun <140559986+sfc-gh-ext-simba-jy@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:19:09 +0900 Subject: [PATCH] SNOW-824882 SSO token cache (#820) --- lib/authentication/auth_idtoken.js | 43 +++++ lib/authentication/auth_web.js | 1 + lib/authentication/authentication.js | 8 +- .../secure_storage/json_credential_manager.js | 107 ++++++++++++ lib/connection/connection.js | 51 +++--- lib/connection/connection_config.js | 37 ++++- lib/constants/error_messages.js | 2 + lib/constants/gs_errors.js | 1 + lib/core.js | 8 + lib/errors.js | 2 + lib/global_config.js | 14 ++ lib/services/sf.js | 25 ++- lib/util.js | 44 +++++ test/integration/connectionOptions.js | 1 + test/integration/testManualConnection.js | 77 ++++++++- .../authentication/authentication_test.js | 18 +- .../custom_credential_manager_test.js | 101 ++++++++++++ .../json_credential_manager_test.js | 49 ++++++ .../unit/connection/connection_config_test.js | 13 +- test/unit/mock/mock_http_client.js | 32 +++- test/unit/mock/mock_test_util.js | 10 ++ test/unit/snowflake_config_test.js | 8 +- test/unit/util_test.js | 156 ++++++++++++++++++ 23 files changed, 770 insertions(+), 38 deletions(-) create mode 100644 lib/authentication/auth_idtoken.js create mode 100644 lib/authentication/secure_storage/json_credential_manager.js create mode 100644 test/unit/authentication/custom_credential_manager_test.js create mode 100644 test/unit/authentication/json_credential_manager_test.js diff --git a/lib/authentication/auth_idtoken.js b/lib/authentication/auth_idtoken.js new file mode 100644 index 000000000..513bec507 --- /dev/null +++ b/lib/authentication/auth_idtoken.js @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2015-2024 Snowflake Computing Inc. All rights reserved. + */ + +const AuthWeb = require('./auth_web'); + +/** + * Creates an ID token authenticator. + * + * @param {String} token + * + * @returns {Object} + * @constructor + */ +function AuthIDToken(connectionConfig, httpClient) { + + this.idToken = connectionConfig.idToken; + + /** + * Update JSON body with token. + * + * @param {JSON} body + * + * @returns {null} + */ + this.updateBody = function (body) { + body['data']['TOKEN'] = this.idToken; + body['data']['AUTHENTICATOR'] = 'ID_TOKEN'; + }; + + this.authenticate = async function () {}; + + this.reauthenticate = async function (body) { + const auth = new AuthWeb(connectionConfig, httpClient); + await auth.authenticate(connectionConfig.getAuthenticator(), + connectionConfig.getServiceName(), + connectionConfig.account, + connectionConfig.username); + auth.updateBody(body); + }; +} + +module.exports = AuthIDToken; diff --git a/lib/authentication/auth_web.js b/lib/authentication/auth_web.js index abd45b1f0..4055422b4 100644 --- a/lib/authentication/auth_web.js +++ b/lib/authentication/auth_web.js @@ -51,6 +51,7 @@ function AuthWeb(connectionConfig, httpClient, webbrowser) { this.updateBody = function (body) { body['data']['TOKEN'] = token; body['data']['PROOF_KEY'] = proofKey; + body['data']['AUTHENTICATOR'] = 'EXTERNALBROWSER'; }; /** diff --git a/lib/authentication/authentication.js b/lib/authentication/authentication.js index 379fcbeb0..a014c38e9 100644 --- a/lib/authentication/authentication.js +++ b/lib/authentication/authentication.js @@ -7,6 +7,7 @@ const AuthWeb = require('./auth_web'); const AuthKeypair = require('./auth_keypair'); const AuthOauth = require('./auth_oauth'); const AuthOkta = require('./auth_okta'); +const AuthIDToken = require('./auth_idtoken'); const Logger = require('../logger'); let authenticator; @@ -17,6 +18,7 @@ const authenticationTypes = EXTERNAL_BROWSER_AUTHENTICATOR: 'EXTERNALBROWSER', KEY_PAIR_AUTHENTICATOR: 'SNOWFLAKE_JWT', OAUTH_AUTHENTICATOR: 'OAUTH', + ID_TOKEN_AUTHENTICATOR: 'ID_TOKEN', }; exports.authenticationTypes = authenticationTypes; @@ -76,7 +78,11 @@ exports.getAuthenticator = function getAuthenticator(connectionConfig, httpClien if (authType === authenticationTypes.DEFAULT_AUTHENTICATOR) { auth = new AuthDefault(connectionConfig.password); } else if (authType === authenticationTypes.EXTERNAL_BROWSER_AUTHENTICATOR) { - auth = new AuthWeb(connectionConfig, httpClient); + if (connectionConfig.getClientStoreTemporaryCredential() && !!connectionConfig.idToken) { + auth = new AuthIDToken(connectionConfig, httpClient); + } else { + auth = new AuthWeb(connectionConfig, httpClient); + } } else if (authType === authenticationTypes.KEY_PAIR_AUTHENTICATOR) { auth = new AuthKeypair(connectionConfig); } else if (authType === authenticationTypes.OAUTH_AUTHENTICATOR) { diff --git a/lib/authentication/secure_storage/json_credential_manager.js b/lib/authentication/secure_storage/json_credential_manager.js new file mode 100644 index 000000000..b2c5d1e50 --- /dev/null +++ b/lib/authentication/secure_storage/json_credential_manager.js @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2015-2024 Snowflake Computing Inc. All rights reserved. + */ + +const path = require('path'); +const Logger = require('../../logger'); +const fs = require('node:fs/promises'); +const os = require('os'); +const Util = require('../../util'); + +function JsonCredentialManager(credentialCacheDir) { + + async function validatePermission(filePath) { + try { + await fs.access(filePath, fs.constants.F_OK); + } catch (err) { + return; + } + const mode = (await fs.stat(filePath)).mode; + const permission = mode & 0o600; + + //This should be 600 permission, which means the file permission has not been changed by others. + if (permission.toString(8) === '600') { + Logger.getInstance().debug('Validated that the user has read and write permission'); + } else { + throw new Error('You do not have read permission or the file has been changed on the user side. Please remove the token file and re run the driver.'); + } + } + + this.getTokenDir = async function () { + let tokenDir = credentialCacheDir; + if (!Util.exists(tokenDir)) { + tokenDir = os.homedir(); + } else { + Logger.getInstance().info(`The credential cache directory is configured by the user. The token will be saved at ${tokenDir}`); + } + + if (!Util.exists(tokenDir)) { + throw new Error(`Temporary credential cache directory is invalid, and the driver is unable to use the default location(home). + Please assign the environment variable value SF_TEMPORARY_CREDENTIAL_CACHE_DIR to enable the default credential manager.`); + } + + const tokenCacheFile = path.join(tokenDir, 'temporary_credential.json'); + await validatePermission(tokenCacheFile); + return tokenCacheFile; + }; + + this.readJsonCredentialFile = async function () { + try { + const cred = await fs.readFile(await this.getTokenDir(), 'utf8'); + return JSON.parse(cred); + } catch (err) { + Logger.getInstance().warn('Failed to read token data from the file. Please check the permission or the file format of the token.'); + return null; + } + }; + + this.write = async function (key, token) { + if (!validateTokenCacheOption(key)) { + return null; + } + + const jsonCredential = await this.readJsonCredentialFile() || {}; + jsonCredential[key] = token; + + try { + await fs.writeFile(await this.getTokenDir(), JSON.stringify(jsonCredential), { mode: 0o600 }); + } catch (err) { + throw new Error(`Failed to write token data. Please check the permission or the file format of the token. ${err.message}`); + } + }; + + this.read = async function (key) { + if (!validateTokenCacheOption(key)) { + return null; + } + + const jsonCredential = await this.readJsonCredentialFile(); + if (!!jsonCredential && jsonCredential[key]){ + return jsonCredential[key]; + } else { + return null; + } + }; + + this.remove = async function (key) { + if (!validateTokenCacheOption(key)) { + return null; + } + const jsonCredential = await this.readJsonCredentialFile(); + + if (jsonCredential && jsonCredential[key]) { + try { + jsonCredential[key] = null; + await fs.writeFile(await this.getTokenDir(), JSON.stringify(jsonCredential), { mode: 0o600 }); + } catch (err) { + throw new Error(`Failed to write token data from the file in ${await this.getTokenDir()}. Please check the permission or the file format of the token. ${err.message}`); + } + } + }; + + function validateTokenCacheOption(key) { + return Util.checkParametersDefined(key); + } +} + +module.exports = JsonCredentialManager; \ No newline at end of file diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 1a1764a85..9d7087c97 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -17,6 +17,8 @@ const Authenticator = require('../authentication/authentication'); const Logger = require('../logger'); const { isOktaAuth } = require('../authentication/authentication'); const { init: initEasyLogging } = require('../logger/easy_logging_starter'); +const GlobalConfig = require('../global_config'); +const JsonCredentialManager = require('../authentication/secure_storage/json_credential_manager'); const PRIVATELINK_URL_SUFFIX = '.privatelink.snowflakecomputing.com'; @@ -275,7 +277,17 @@ function Connection(context) { // connect to the snowflake service and provide our own callback so that // the connection can be passed in when invoking the connection.connect() // callback + const self = this; + + if (connectionConfig.getClientStoreTemporaryCredential()) { + const key = Util.buildCredentialCacheKey(connectionConfig.host, + connectionConfig.username, Authenticator.authenticationTypes.ID_TOKEN_AUTHENTICATOR); + if (GlobalConfig.getCredentialManager() === null) { + GlobalConfig.setCustomCredentialManager(new JsonCredentialManager(connectionConfig.getCredentialCacheDir())); + } + connectionConfig.idToken = await GlobalConfig.getCredentialManager().read(key); + } // Get authenticator to use const auth = Authenticator.getAuthenticator(connectionConfig, context.getHttpClient()); @@ -290,29 +302,28 @@ function Connection(context) { await auth.authenticate(connectionConfig.getAuthenticator(), connectionConfig.getServiceName(), connectionConfig.account, - connectionConfig.username) - .then(() => { - // JSON for connection - const body = Authenticator.formAuthJSON(connectionConfig.getAuthenticator(), - connectionConfig.account, - connectionConfig.username, - connectionConfig.getClientType(), - connectionConfig.getClientVersion(), - connectionConfig.getClientEnvironment()); - - // Update JSON body with the authentication values - auth.updateBody(body); - - // Request connection - services.sf.connect({ - callback: connectCallback(self, callback), - json: body - }); - }); + connectionConfig.username); + + // JSON for connection + const body = Authenticator.formAuthJSON(connectionConfig.getAuthenticator(), + connectionConfig.account, + connectionConfig.username, + connectionConfig.getClientType(), + connectionConfig.getClientVersion(), + connectionConfig.getClientEnvironment()); + + // Update JSON body with the authentication values + auth.updateBody(body); + + // Request connection + services.sf.connect({ + callback: connectCallback(self, callback), + json: body, + }); } catch (authErr) { callback(authErr); } - + // return the connection to facilitate chaining return this; }; diff --git a/lib/connection/connection_config.js b/lib/connection/connection_config.js index f143f8099..1ad551ec4 100644 --- a/lib/connection/connection_config.js +++ b/lib/connection/connection_config.js @@ -6,6 +6,7 @@ const os = require('os'); const url = require('url'); const Util = require('../util'); const Errors = require('../errors'); +const path = require('path'); const fs = require('fs'); const ErrorCodes = Errors.codes; const NativeTypes = require('./result/data_types').NativeTypes; @@ -14,6 +15,8 @@ const authenticationTypes = require('../authentication/authentication').authenti const levenshtein = require('fastest-levenshtein'); const RowMode = require('./../constants/row_mode'); const DataTypes = require('./result/data_types'); +const Logger = require('../logger'); +const { validateOnlyUserReadWritePermission } = require('../file_transfer_agent/file_util'); const WAIT_FOR_BROWSER_ACTION_TIMEOUT = 120000; const DEFAULT_PARAMS = [ @@ -54,13 +57,13 @@ const DEFAULT_PARAMS = 'includeRetryReason', 'disableQueryContextCache', 'retryTimeout', + 'clientStoreTemporaryCredential', 'disableConsoleLogin', 'forceGCPUseDownscopedCredential', 'representNullAsStringNull', 'disableSamlURLCheck', + 'credentialCacheDir', ]; -const Logger = require('../logger'); -const { validateOnlyUserReadWritePermission } = require('../file_transfer_agent/file_util'); function consolidateHostAndAccount(options) { let dotPos = -1; @@ -521,6 +524,23 @@ function ConnectionConfig(options, validateCredentials, qaMode, clientInfo) { disableSamlURLCheck = options.disableSamlURLCheck; } + let clientStoreTemporaryCredential = false; + if (Util.exists(options.clientStoreTemporaryCredential)) { + Errors.checkArgumentValid(Util.isBoolean(options.clientStoreTemporaryCredential), + ErrorCodes.ERR_CONN_CREATE_INVALID_CLIENT_STORE_TEMPORARY_CREDENTIAL); + + clientStoreTemporaryCredential = options.clientStoreTemporaryCredential; + } + + let credentialCacheDir = null; + if (Util.exists(options.credentialCacheDir)) { + const absolutePath = path.resolve(options.credentialCacheDir); + Errors.checkArgumentValid(Util.validatePath(absolutePath), + ErrorCodes.ERR_CONN_CREATE_INVALID_CREDENTIAL_CACHE_DIR); + + credentialCacheDir = absolutePath; + } + /** * Returns an object that contains information about the proxy hostname, port, * etc. for when http requests are made. @@ -810,6 +830,19 @@ function ConnectionConfig(options, validateCredentials, qaMode, clientInfo) { return disableSamlURLCheck; }; + this.getCredentialCacheDir = function () { + return credentialCacheDir; + }; + + /** + * Returns whether the auth token saves on the local machine or not. + * + * @returns {Boolean} + */ + this.getClientStoreTemporaryCredential = function () { + return clientStoreTemporaryCredential; + }; + // save config options this.username = options.username; this.password = options.password; diff --git a/lib/constants/error_messages.js b/lib/constants/error_messages.js index be38298ce..44f48fb79 100644 --- a/lib/constants/error_messages.js +++ b/lib/constants/error_messages.js @@ -23,6 +23,7 @@ exports[403003] = 'Invalid OCSP mode. The specified value must be FAIL_CLOSED, F exports[403004] = 'Invalid custom JSON parser. The specified value must be a function.'; exports[403005] = 'Invalid custom XML parser. The specified value must be a function.'; exports[403006] = 'Invalid keep alive value. The specified value must be a boolean.'; +exports[403007] = 'Invalid custom credential manager value. The specified value must be an object, and it should have three methods: write, read, remove'; // 404001 exports[404001] = 'Connection options must be specified.'; @@ -73,6 +74,7 @@ exports[404045] = 'Invalid account. The specified value must be a valid subdomai exports[404046] = 'Invalid region. The specified value must be a valid subdomain string.'; exports[404047] = 'Invalid disableConsoleLogin. The specified value must be a boolean'; exports[404048] = 'Invalid forceGCPUseDownscopedCredential. The specified value must be a boolean'; +exports[404049] = 'Invalid clientStoreTemporaryCredential. The specified value must be a boolean.'; exports[404050] = 'Invalid representNullAsStringNull. The specified value must be a boolean'; exports[404051] = 'Invalid disableSamlURLCheck. The specified value must be a boolean'; diff --git a/lib/constants/gs_errors.js b/lib/constants/gs_errors.js index 178be5d4c..2fc8ff7b2 100644 --- a/lib/constants/gs_errors.js +++ b/lib/constants/gs_errors.js @@ -9,5 +9,6 @@ code.SESSION_TOKEN_INVALID = '390104'; code.GONE_SESSION = '390111'; code.SESSION_TOKEN_EXPIRED = '390112'; code.MASTER_TOKEN_EXPIRED = '390114'; +code.ID_TOKEN_INVALID = '390195'; exports.code = code; \ No newline at end of file diff --git a/lib/core.js b/lib/core.js index f7b46025a..2f812c790 100644 --- a/lib/core.js +++ b/lib/core.js @@ -206,6 +206,14 @@ function Core(options) { GlobalConfig.setKeepAlive(keepAlive); } + + const customCredentialManager = options.customCredentialManager; + if (Util.exists(customCredentialManager)) { + Errors.checkArgumentValid(Util.isObject(customCredentialManager), + ErrorCodes.ERR_GLOBAL_CONFIGURE_INVALID_CUSTOM_CREDENTIAL_MANAGER); + + GlobalConfig.setCustomCredentialManager(customCredentialManager); + } } }; diff --git a/lib/errors.js b/lib/errors.js index ce8da75ba..a657622c0 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -28,6 +28,7 @@ codes.ERR_GLOBAL_CONFIGURE_INVALID_OCSP_MODE = 403003; codes.ERR_GLOBAL_CONFIGURE_INVALID_JSON_PARSER = 403004; codes.ERR_GLOBAL_CONFIGURE_INVALID_XML_PARSER = 403005; codes.ERR_GLOBAL_CONFIGURE_INVALID_KEEP_ALIVE = 403006; +codes.ERR_GLOBAL_CONFIGURE_INVALID_CUSTOM_CREDENTIAL_MANAGER = 403007; // 404001 codes.ERR_CONN_CREATE_MISSING_OPTIONS = 404001; @@ -78,6 +79,7 @@ codes.ERR_CONN_CREATE_INVALID_ACCOUNT_REGEX = 404045; codes.ERR_CONN_CREATE_INVALID_REGION_REGEX = 404046; codes.ERR_CONN_CREATE_INVALID_DISABLE_CONSOLE_LOGIN = 404047; codes.ERR_CONN_CREATE_INVALID_FORCE_GCP_USE_DOWNSCOPED_CREDENTIAL = 404048; +codes.ERR_CONN_CREATE_INVALID_CLIENT_STORE_TEMPORARY_CREDENTIAL = 404049; codes.ERR_CONN_CREATE_INVALID_REPRESENT_NULL_AS_STRING_NULL = 404050; codes.ERR_CONN_CREATE_INVALID_DISABLE_SAML_URL_CHECK = 404051; diff --git a/lib/global_config.js b/lib/global_config.js index dfa58d6b8..4f2994cb5 100644 --- a/lib/global_config.js +++ b/lib/global_config.js @@ -8,6 +8,7 @@ const mkdirp = require('mkdirp'); const Util = require('./util'); const Errors = require('./errors'); +const ErrorCodes = Errors.codes; const Logger = require('./logger'); const { XMLParser, XMLValidator } = require('fast-xml-parser'); @@ -256,4 +257,17 @@ exports.getKeepAlive = function () { return keepAlive; }; +let credentialManager = null; + +exports.setCustomCredentialManager = function (customCredentialManager) { + Errors.checkArgumentValid(Util.checkValidCustomCredentialManager(customCredentialManager), + ErrorCodes.ERR_GLOBAL_CONFIGURE_INVALID_CUSTOM_CREDENTIAL_MANAGER); + + credentialManager = customCredentialManager; + Logger.getInstance().info('Custom credential manager is set by a user.'); +}; + +exports.getCredentialManager = function () { + return credentialManager; +}; diff --git a/lib/services/sf.js b/lib/services/sf.js index f75f1e5a2..e92adbd95 100644 --- a/lib/services/sf.js +++ b/lib/services/sf.js @@ -55,7 +55,8 @@ const Parameters = require('../parameters'); const GSErrors = require('../constants/gs_errors'); const QueryContextCache = require('../queryContextCache'); const Logger = require('../logger'); -const { getCurrentAuth } = require('../authentication/authentication'); +const GlobalConfig = require('../global_config'); +const { authenticationTypes, getCurrentAuth } = require('../authentication/authentication'); const AuthOkta = require('../authentication/auth_okta'); const AuthKeypair = require('../authentication/auth_keypair'); @@ -624,6 +625,17 @@ function StateAbstract(options) { // success flag is false, the operation we tried to perform failed if (body && !body.success) { const data = body.data; + const auth = getCurrentAuth(); + + if (body.code === GSErrors.code.ID_TOKEN_INVALID && data.authnMethod === 'TOKEN') { + Logger.getInstance().debug('ID Token being used has expired. Reauthenticating'); + const key = Util.buildCredentialCacheKey(connectionConfig.host, + connectionConfig.username, authenticationTypes.ID_TOKEN_AUTHENTICATOR); + await GlobalConfig.getCredentialManager().remove(key); + await auth.reauthenticate(requestOptions.json); + return httpClient.request(realRequestOptions); + } + err = Errors.createOperationFailedError( body.code, data, body.message, data && data.sqlState ? data.sqlState : undefined); @@ -1064,6 +1076,11 @@ StateConnecting.prototype.continue = function () { sessionParameters.SESSION_PARAMETERS.GCS_USE_DOWNSCOPED_CREDENTIAL = this.connectionConfig.getGcsUseDownscopedCredential(); } + + if (Util.exists(this.connectionConfig.getClientStoreTemporaryCredential())) { + sessionParameters.SESSION_PARAMETERS.CLIENT_STORE_TEMPORARY_CREDENTIAL = + this.connectionConfig.getClientStoreTemporaryCredential(); + } Util.apply(json.data, clientInfo); Util.apply(json.data, sessionParameters); @@ -1093,7 +1110,11 @@ StateConnecting.prototype.continue = function () { // update all token-related information parent.tokenInfo.update(body.data); - + if (connectionConfig.getClientStoreTemporaryCredential() && body.data.idToken) { + const key = Util.buildCredentialCacheKey(connectionConfig.host, + connectionConfig.username, authenticationTypes.ID_TOKEN_AUTHENTICATOR); + await GlobalConfig.getCredentialManager().write(key, body.data.idToken); + } // we're now connected parent.snowflakeService.transitionToConnected(); diff --git a/lib/util.js b/lib/util.js index e8bc492ea..5f1431180 100644 --- a/lib/util.js +++ b/lib/util.js @@ -5,6 +5,8 @@ const util = require('util'); const Url = require('url'); const os = require('os'); +const Logger = require('./logger'); +const fs = require('fs'); /** * Note: A simple wrapper around util.inherits() for now, but this might change @@ -660,6 +662,38 @@ exports.removeScheme = function (input) { return input.toString().replace(/(^\w+:|^)\/\//, ''); }; +exports.buildCredentialCacheKey = function (host, username, credType) { + if (!host || !username || !credType) { + Logger.getInstance().debug('Cannot build the credential cache key because one of host, username, and credType is null'); + return null; + } + return `{${host.toUpperCase()}}:{${username.toUpperCase()}}:{SF_NODE_JS_DRIVER}:{${credType.toUpperCase()}}`; +}; + +/** + * + * @param {Object} customCredentialManager + * @returns + */ +exports.checkValidCustomCredentialManager = function (customCredentialManager) { + if ( typeof customCredentialManager !== 'object') { + return false; + } + + const requireMethods = ['write', 'read', 'remove']; + + for (const method of requireMethods) { + if (!Object.hasOwnProperty.call(customCredentialManager, method) || typeof customCredentialManager[method] !== 'function') { + return false; + } + } + return true; +}; + +exports.checkParametersDefined = function (...parameters) { + return parameters.every((element) => element !== undefined && element !== null); +}; + exports.shouldPerformGCPBucket = function (accessToken) { return !!accessToken && process.env.SNOWFLAKE_FORCE_GCP_USE_DOWNSCOPED_CREDENTIAL !== 'true'; }; @@ -704,3 +738,13 @@ exports.shouldRetryOktaAuth = function ({ maxRetryTimeout, maxRetryCount, numRet exports.getDriverDirectory = function () { return __dirname; }; + +exports.validatePath = function (dir) { + try { + const stat = fs.statSync(dir); + return stat.isDirectory(); + } catch { + Logger.getInstance().error('The path location is invalid. Please check this location is accessible or existing'); + return false; + } +}; diff --git a/test/integration/connectionOptions.js b/test/integration/connectionOptions.js index 3ef4fe990..cbbc0e791 100644 --- a/test/integration/connectionOptions.js +++ b/test/integration/connectionOptions.js @@ -98,6 +98,7 @@ const externalBrowser = database: snowflakeTestDatabase, schema: snowflakeTestSchema, role: snowflakeTestRole, + host: snowflakeTestHost, authenticator: 'EXTERNALBROWSER' }; diff --git a/test/integration/testManualConnection.js b/test/integration/testManualConnection.js index 80c34b65d..823fcfda1 100644 --- a/test/integration/testManualConnection.js +++ b/test/integration/testManualConnection.js @@ -8,10 +8,12 @@ const assert = require('assert'); const connOption = require('./connectionOptions'); const testUtil = require('./testUtil'); const Logger = require('../../lib/logger'); +const Util = require('../../lib/util'); +const JsonCredentialManager = require('../../lib/authentication/secure_storage/json_credential_manager'); const { loadConnectionConfiguration } = require('../../lib/configuration/connection_configuration'); if (process.env.RUN_MANUAL_TESTS_ONLY === 'true') { - describe.only('Run manual tests', function () { + describe('Run manual tests', function () { describe('Connection test - external browser', function () { it('Simple Connect', function (done) { const connection = snowflake.createConnection( @@ -77,6 +79,79 @@ if (process.env.RUN_MANUAL_TESTS_ONLY === 'true') { }); }); + describe('Connection - ID Token authenticator', function () { + const connectionOption = { ...connOption.externalBrowser, clientStoreTemporaryCredential: true }; + const key = Util.buildCredentialCacheKey(connectionOption.host, connectionOption.username, 'ID_TOKEN'); + const defaultCredentialManager = new JsonCredentialManager(); + let oldToken; + before( async () => { + await defaultCredentialManager.remove(key); + }); + + it('test - obtain the id token from the server and save it on the local storage', function (done) { + const connection = snowflake.createConnection(connectionOption); + connection.connectAsync(function (err) { + try { + assert.ok(!err); + done(); + } catch (err){ + done(err); + } + }); + }); + + it('test - the token is saved in the credential manager correctly', function (done) { + defaultCredentialManager.read(key).then((idToken) => { + try { + oldToken = idToken; + assert.notStrictEqual(idToken, null); + done(); + } catch (err){ + done(err); + } + }); + }); + + + // Web Browser should not be open. + it('test - id token authentication', function (done) { + snowflake.configure({ logLevel: 'TRACE', insecureMode: true }); + const idTokenConnection = snowflake.createConnection(connectionOption); + try { + idTokenConnection.connectAsync(function (err) { + assert.ok(!err); + done(); + }); + } catch (err) { + done(err); + } + }); + + // Web Browser should be open. + it('test - id token reauthentication', function (done) { + defaultCredentialManager.write(key, '1234').then(() => { + const wrongTokenConnection = snowflake.createConnection(connectionOption); + wrongTokenConnection.connectAsync(function (err) { + assert.ok(!err); + done(); + }); + }); + }); + + //Compare two idToken. Those two should be different. + it('test - the token is refreshed', function (done) { + oldToken = undefined; + defaultCredentialManager.read(key).then((idToken) => { + try { + assert.notStrictEqual(idToken, oldToken); + done(); + } catch (err) { + done(err); + } + }); + }); + }); + describe('Connection test - oauth', function () { it('Simple Connect', function (done) { const connection = snowflake.createConnection(connOption.oauth); diff --git a/test/unit/authentication/authentication_test.js b/test/unit/authentication/authentication_test.js index f480c71bd..50201f017 100644 --- a/test/unit/authentication/authentication_test.js +++ b/test/unit/authentication/authentication_test.js @@ -12,6 +12,7 @@ const AuthWeb = require('./../../../lib/authentication/auth_web'); const AuthKeypair = require('./../../../lib/authentication/auth_keypair'); const AuthOauth = require('./../../../lib/authentication/auth_oauth'); const AuthOkta = require('./../../../lib/authentication/auth_okta'); +const AuthIDToken = require('./../../../lib/authentication/auth_idtoken'); const authenticationTypes = require('./../../../lib/authentication/authentication').authenticationTypes; const MockTestUtil = require('./../mock/mock_test_util'); @@ -25,6 +26,7 @@ const connectionOptionsKeyPair = mockConnectionOptions.authKeyPair; const connectionOptionsKeyPairPath = mockConnectionOptions.authKeyPairPath; const connectionOptionsOauth = mockConnectionOptions.authOauth; const connectionOptionsOkta = mockConnectionOptions.authOkta; +const connectionOptionsIdToken = mockConnectionOptions.authIdToken; describe('default authentication', function () { @@ -181,6 +183,17 @@ describe('external browser authentication', function () { assert.strictEqual( body['data']['AUTHENTICATOR'], authenticationTypes.EXTERNAL_BROWSER_AUTHENTICATOR, 'Authenticator should be EXTERNALBROWSER'); }); + + it('external browser - id token', async function () { + const auth = new AuthIDToken(connectionOptionsIdToken, httpclient); + await auth.authenticate(credentials.authenticator, '', credentials.account, credentials.username, credentials.host); + + const body = { data: {} }; + auth.updateBody(body); + + assert.strictEqual(body['data']['TOKEN'], connectionOptionsIdToken.idToken); + assert.strictEqual(body['data']['AUTHENTICATOR'], authenticationTypes.ID_TOKEN_AUTHENTICATOR); + }); }); describe('key-pair authentication', function () { @@ -607,11 +620,12 @@ describe('okta authentication', function () { [ { name: 'default', providedAuth: authenticationTypes.DEFAULT_AUTHENTICATOR, expectedAuth: 'AuthDefault' }, { name: 'external browser', providedAuth: authenticationTypes.EXTERNAL_BROWSER_AUTHENTICATOR, expectedAuth: 'AuthWeb' }, + { name: 'id token', providedAuth: authenticationTypes.EXTERNAL_BROWSER_AUTHENTICATOR, expectedAuth: 'AuthIDToken', idToken: 'idToken' }, { name: 'key pair', providedAuth: authenticationTypes.KEY_PAIR_AUTHENTICATOR, expectedAuth: 'AuthKeypair' }, { name: 'oauth', providedAuth: authenticationTypes.OAUTH_AUTHENTICATOR, expectedAuth: 'AuthOauth' }, { name: 'okta', providedAuth: 'https://mycustom.okta.com:8443', expectedAuth: 'AuthOkta' }, { name: 'unknown', providedAuth: 'unknown', expectedAuth: 'AuthDefault' } - ].forEach(({ name, providedAuth, expectedAuth }) => { + ].forEach(({ name, providedAuth, expectedAuth, idToken }) => { it(`${name}`, () => { const connectionConfig = { getBrowserActionTimeout: () => 100, @@ -625,6 +639,8 @@ describe('okta authentication', function () { getToken: () => '', getClientType: () => '', getClientVersion: () => '', + getClientStoreTemporaryCredential: () => true, + idToken: idToken || null, host: 'host', }; diff --git a/test/unit/authentication/custom_credential_manager_test.js b/test/unit/authentication/custom_credential_manager_test.js new file mode 100644 index 000000000..97017791b --- /dev/null +++ b/test/unit/authentication/custom_credential_manager_test.js @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2015-2024 Snowflake Computing Inc. All rights reserved. + */ + +const assert = require('assert'); +const Util = require('../../../lib/util'); +const { randomUUID } = require('crypto'); +const GlobalConfig = require('../../../lib/global_config'); +const JsonCredentialManager = require('../../../lib/authentication/secure_storage/json_credential_manager'); +const host = 'mock_host'; +const user = 'mock_user'; +const credType = 'mock_cred'; +const key = Util.buildCredentialCacheKey(host, user, credType); +const randomPassword = randomUUID(); +const defaultCredentialManager = new JsonCredentialManager(); +const mockCustomCrednetialManager = { + read: function () { + return 'mock_token'; + }, + write: function () { + return 'token_saved'; + }, + remove: function () { + return null; + } +}; + +describe('test - getter and setter for customCrendentialManager', () => { + + after(() => { + GlobalConfig.setCustomCredentialManager(defaultCredentialManager); + }); + + it('test setCustomCredentialManager', () => { + GlobalConfig.setCustomCredentialManager(mockCustomCrednetialManager); + assert.strictEqual(GlobalConfig.getCredentialManager(), mockCustomCrednetialManager); + }); +}); + +describe('test - synchronous customCredentialManager', function () { + + before(() => { + GlobalConfig.setCustomCredentialManager(mockCustomCrednetialManager); + }); + + after(() => { + GlobalConfig.setCustomCredentialManager(defaultCredentialManager); + }); + + it('test - custom credential manager read function', function () { + const token = GlobalConfig.getCredentialManager().read(key); + assert.strictEqual(token, 'mock_token'); + }); + + it('test - custom credential manager write function', function () { + const result = GlobalConfig.getCredentialManager().write(key, randomPassword); + assert.strictEqual(result, 'token_saved'); + }); + + it('test - custom credential manager remove function', function () { + const result = GlobalConfig.getCredentialManager().remove(key); + assert.strictEqual(result, null); + }); +}); + +describe('test - asynchronous customCredentialManager', function () { + + before(() => { + GlobalConfig.setCustomCredentialManager({ + read: async function () { + return 'mock_token'; + }, + write: async function () { + return 'token_saved'; + }, + remove: async function () { + return null; + } + }); + }); + + after(() => { + GlobalConfig.setCustomCredentialManager(defaultCredentialManager); + }); + + it('test - custom credential manager read function', async function () { + const token = await GlobalConfig.getCredentialManager().read(key); + assert.strictEqual(token, 'mock_token'); + }); + + it('test - custom credential manager write function', function () { + GlobalConfig.getCredentialManager().write(key, randomPassword).then((result) => { + assert.strictEqual(result, 'token_saved'); + }); + }); + + it('test - custom credential manager remove function', async function () { + const result = await GlobalConfig.getCredentialManager().remove(key); + assert.strictEqual(result, null); + }); +}); \ No newline at end of file diff --git a/test/unit/authentication/json_credential_manager_test.js b/test/unit/authentication/json_credential_manager_test.js new file mode 100644 index 000000000..c0a882e98 --- /dev/null +++ b/test/unit/authentication/json_credential_manager_test.js @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2015-2024 Snowflake Computing Inc. All rights reserved. + */ + +const assert = require('assert'); +const JsonCredentialManager = require('../../../lib/authentication/secure_storage/json_credential_manager'); +const Util = require('../../../lib/util'); +const { randomUUID } = require('crypto'); +const path = require('path'); +const host = 'mock_host'; +const user = 'mock_user'; +const credType = 'mock_cred'; +const key = Util.buildCredentialCacheKey(host, user, credType); +const randomPassword = randomUUID(); +const os = require('os'); +const currentNodeVersion = parseInt(process.version.slice(1), 10); + +if (!(currentNodeVersion <= 14 && (os.platform() === 'win32'))) { + describe('Json credential manager test', function () { + const credentialManager = new JsonCredentialManager(); + + it('test - initiate credential manager', async function () { + if (await credentialManager.read(key) !== null) { + await credentialManager.remove(key); + } + const savedPassword = await credentialManager.read(key); + + assert.strictEqual(await credentialManager.getTokenDir(), path.join(os.homedir(), 'temporary_credential.json')); + assert.strictEqual(savedPassword, null); + }); + + it('test - write the mock credential with the credential manager', async function () { + await credentialManager.write(key, randomPassword); + const result = await credentialManager.read(key); + assert.strictEqual(randomPassword, result); + }); + + it('test - delete the mock credential with the credential manager', async function () { + await credentialManager.remove(key); + const result = await credentialManager.read(key); + assert.ok(result === null); + }); + + it('test - token saving location when the user sets credentialCacheDir value', async function () { + const credManager = new JsonCredentialManager(os.tmpdir()); + assert.strictEqual(await credManager.getTokenDir(), path.join(os.tmpdir(), 'temporary_credential.json')); + }); + }); +} \ No newline at end of file diff --git a/test/unit/connection/connection_config_test.js b/test/unit/connection/connection_config_test.js index 555866ebe..36c9f34e2 100644 --- a/test/unit/connection/connection_config_test.js +++ b/test/unit/connection/connection_config_test.js @@ -670,6 +670,17 @@ describe('ConnectionConfig: basic', function () { }, errorCode: ErrorCodes.ERR_CONN_CREATE_INVALID_INCLUDE_RETRY_REASON, }, + { + name: 'invalid clientStoreTemporaryCredential', + options: + { + username: 'username', + password: 'password', + account: 'account', + clientStoreTemporaryCredential: 'invalid' + }, + errorCode: ErrorCodes.ERR_CONN_CREATE_INVALID_CLIENT_STORE_TEMPORARY_CREDENTIAL, + }, { name: 'invalid clientConfigFile', options: { @@ -1398,7 +1409,7 @@ describe('ConnectionConfig: basic', function () { password: 'password', account: 'account' }; - + const testCases = [ { diff --git a/test/unit/mock/mock_http_client.js b/test/unit/mock/mock_http_client.js index e0c7f369c..27799aa12 100644 --- a/test/unit/mock/mock_http_client.js +++ b/test/unit/mock/mock_http_client.js @@ -203,7 +203,9 @@ function buildRequestOutputMappings(clientInfo) { CLIENT_APP_ID: 'JavaScript', CLIENT_APP_VERSION: clientInfo.version, CLIENT_ENVIRONMENT: clientInfo.environment, - SESSION_PARAMETERS: {} + SESSION_PARAMETERS: { + CLIENT_STORE_TEMPORARY_CREDENTIAL: false, + } } } }, @@ -1220,7 +1222,9 @@ function buildRequestOutputMappings(clientInfo) { CLIENT_APP_ID: 'JavaScript', CLIENT_APP_VERSION: clientInfo.version, CLIENT_ENVIRONMENT: clientInfo.environment, - SESSION_PARAMETERS: {} + SESSION_PARAMETERS: { + CLIENT_STORE_TEMPORARY_CREDENTIAL: false, + } } } }, @@ -1439,7 +1443,8 @@ function buildRequestOutputMappings(clientInfo) { CLIENT_ENVIRONMENT: clientInfo.environment, SESSION_PARAMETERS: { CLIENT_SESSION_KEEP_ALIVE: true, - CLIENT_SESSION_KEEP_ALIVE_HEARTBEAT_FREQUENCY: 1800 + CLIENT_SESSION_KEEP_ALIVE_HEARTBEAT_FREQUENCY: 1800, + CLIENT_STORE_TEMPORARY_CREDENTIAL: false, } } } @@ -1546,6 +1551,7 @@ function buildRequestOutputMappings(clientInfo) { CLIENT_ENVIRONMENT: clientInfo.environment, SESSION_PARAMETERS: { JS_TREAT_INTEGER_AS_BIGINT: true, + CLIENT_STORE_TEMPORARY_CREDENTIAL: false, } } } @@ -1651,7 +1657,9 @@ function buildRequestOutputMappings(clientInfo) { CLIENT_APP_ID: 'JavaScript', CLIENT_APP_VERSION: clientInfo.version, CLIENT_ENVIRONMENT: clientInfo.environment, - SESSION_PARAMETERS: {} + SESSION_PARAMETERS: { + CLIENT_STORE_TEMPORARY_CREDENTIAL: false, + } } } }, @@ -1741,7 +1749,9 @@ function buildRequestOutputMappings(clientInfo) { CLIENT_APP_ID: 'JavaScript', CLIENT_APP_VERSION: clientInfo.version, CLIENT_ENVIRONMENT: clientInfo.environment, - SESSION_PARAMETERS: {} + SESSION_PARAMETERS: { + CLIENT_STORE_TEMPORARY_CREDENTIAL: false, + } } } }, @@ -1830,7 +1840,9 @@ function buildRequestOutputMappings(clientInfo) { CLIENT_APP_ID: 'JavaScript', CLIENT_APP_VERSION: clientInfo.version, CLIENT_ENVIRONMENT: clientInfo.environment, - SESSION_PARAMETERS: {} + SESSION_PARAMETERS: { + CLIENT_STORE_TEMPORARY_CREDENTIAL: false, + } } } }, @@ -1869,7 +1881,9 @@ function buildRequestOutputMappings(clientInfo) { CLIENT_APP_ID: 'JavaScript', CLIENT_APP_VERSION: clientInfo.version, CLIENT_ENVIRONMENT: clientInfo.environment, - SESSION_PARAMETERS: {} + SESSION_PARAMETERS: { + CLIENT_STORE_TEMPORARY_CREDENTIAL: false, + } } } }, @@ -1907,7 +1921,9 @@ function buildRequestOutputMappings(clientInfo) { CLIENT_APP_ID: 'JavaScript', CLIENT_APP_VERSION: clientInfo.version, CLIENT_ENVIRONMENT: clientInfo.environment, - SESSION_PARAMETERS: {} + SESSION_PARAMETERS: { + CLIENT_STORE_TEMPORARY_CREDENTIAL: false, + } } } }, diff --git a/test/unit/mock/mock_test_util.js b/test/unit/mock/mock_test_util.js index 053ab5b6e..592de8d4a 100644 --- a/test/unit/mock/mock_test_util.js +++ b/test/unit/mock/mock_test_util.js @@ -104,6 +104,15 @@ const connectionOptionsExternalBrowser = authenticator: 'EXTERNALBROWSER' }; +const connectionOptionsidToken = +{ + accessUrl: 'http://fakeaccount.snowflakecomputing.com', + username: 'fakeusername', + account: 'fakeaccount', + idToken: 'fakeIdToken', + authenticator: 'EXTERNALBROWSER' +}; + const connectionOptionsKeyPair = { accessUrl: 'http://fakeaccount.snowflakecomputing.com', @@ -171,4 +180,5 @@ exports.connectionOptions = authKeyPairPath: connectionOptionsKeyPairPath, authOauth: connectionOptionsOauth, authOkta: connectionOptionsOkta, + authIdToken: connectionOptionsidToken, }; diff --git a/test/unit/snowflake_config_test.js b/test/unit/snowflake_config_test.js index 3a0daccf0..a48cb9827 100644 --- a/test/unit/snowflake_config_test.js +++ b/test/unit/snowflake_config_test.js @@ -7,7 +7,6 @@ const snowflake = require('./../../lib/snowflake'); const ErrorCodes = require('./../../lib/errors').codes; const Logger = require('./../../lib/logger'); const GlobalConfig = require('./../../lib/global_config'); - const LOG_LEVEL_TAGS = require('./../../lib/logger/core').LOG_LEVEL_TAGS; describe('Snowflake Configure Tests', function () { @@ -60,7 +59,12 @@ describe('Snowflake Configure Tests', function () { name: 'invalid keep alive', options: { keepAlive: 'unsupported' }, errorCode: ErrorCodes.ERR_GLOBAL_CONFIGURE_INVALID_KEEP_ALIVE - } + }, + { + name: 'invalid customCredentialManager', + options: { customCredentialManager: 'unsupported' }, + errorCode: ErrorCodes.ERR_GLOBAL_CONFIGURE_INVALID_CUSTOM_CREDENTIAL_MANAGER + }, ]; negativeTestCases.forEach(testCase => { diff --git a/test/unit/util_test.js b/test/unit/util_test.js index 4f0cf2b45..c0501c08e 100644 --- a/test/unit/util_test.js +++ b/test/unit/util_test.js @@ -971,6 +971,162 @@ describe('Util', function () { } }); }); + + describe('Util test - custom credential manager util functions', function () { + const mockUser = 'mockUser'; + const mockHost = 'mockHost'; + const mockCred = 'mockCred'; + + describe('test function build credential key', function () { + const testCases = [ + { + name: 'when all the parameters are null', + user: null, + host: null, + cred: null, + result: null + }, + { + name: 'when two parameters are null or undefined', + user: mockUser, + host: null, + cred: undefined, + result: null + }, + { + name: 'when one parameter is null', + user: mockUser, + host: mockHost, + cred: undefined, + result: null + }, + { + name: 'when one parameter is undefined', + user: mockUser, + host: undefined, + cred: mockCred, + result: null + }, + { + name: 'when all the parameters are valid', + user: mockUser, + host: mockHost, + cred: mockCred, + result: '{mockHost}:{mockUser}:{SF_NODE_JS_DRIVER}:{mockCred}}' + }, + ]; + testCases.forEach((name, user, host, cred, result) => { + it(`${name}`, function () { + if (!result) { + assert.strictEqual(Util.buildCredentialCacheKey(host, user, cred), null); + } else { + assert.strictEqual(Util.buildCredentialCacheKey(host, user, cred), result); + } + }); + }); + }); + }); + + describe('test valid custom credential manager', function () { + + function sampleManager() { + this.read = function () {}; + + this.write = function () {}; + + this.remove = function () {}; + } + + const testCases = [ + { + name: 'credential manager is an int', + credentialManager: 123, + result: false, + }, + { + name: 'credential manager is a string', + credentialManager: 'credential manager', + result: false, + }, + { + name: 'credential manager is an array', + credentialManager: ['write', 'read', 'remove'], + result: false, + }, + { + name: 'credential manager is an empty obejct', + credentialManager: {}, + result: false, + }, + { + name: 'credential manager has property, but invalid types', + credentialManager: { + read: 'read', + write: 1234, + remove: [] + }, + result: false, + }, + { + name: 'credential manager has property, but invalid types', + credentialManager: { + read: 'read', + write: 1234, + remove: [] + }, + result: false, + }, + { + name: 'credential manager has two valid properties, but miss one', + credentialManager: { + read: function () { + + }, + write: function () { + + } + }, + result: false, + }, + { + name: 'credential manager has two valid properties, but miss one', + credentialManager: new sampleManager(), + result: true, + }, + ]; + + for (const { name, credentialManager, result } of testCases) { + it(name, function () { + assert.strictEqual(Util.checkValidCustomCredentialManager(credentialManager), result); + }); + } + }); + + describe('checkParametersDefined function Test', function () { + const testCases = [ + { + name: 'all the parameters are null or undefined', + parameters: [null, undefined, null, null], + result: false + }, + { + name: 'one parameter is null', + parameters: ['a', 2, true, null], + result: false + }, + { + name: 'all the parameter are existing', + parameters: ['a', 123, ['testing'], {}], + result: true + }, + ]; + + for (const { name, parameters, result } of testCases) { + it(name, function () { + assert.strictEqual(Util.checkParametersDefined(...parameters), result); + }); + } + }); }); if (os.platform() !== 'win32') {