Skip to content

Commit

Permalink
SNOW-824882 SSO token cache (#820)
Browse files Browse the repository at this point in the history
  • Loading branch information
sfc-gh-ext-simba-jy authored Jun 27, 2024
1 parent 03d557d commit 8f0b0f6
Show file tree
Hide file tree
Showing 23 changed files with 770 additions and 38 deletions.
43 changes: 43 additions & 0 deletions lib/authentication/auth_idtoken.js
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions lib/authentication/auth_web.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
};

/**
Expand Down
8 changes: 7 additions & 1 deletion lib/authentication/authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
107 changes: 107 additions & 0 deletions lib/authentication/secure_storage/json_credential_manager.js
Original file line number Diff line number Diff line change
@@ -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;
51 changes: 31 additions & 20 deletions lib/connection/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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());
Expand All @@ -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;
};
Expand Down
37 changes: 35 additions & 2 deletions lib/connection/connection_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 =
[
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions lib/constants/error_messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down Expand Up @@ -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';

Expand Down
1 change: 1 addition & 0 deletions lib/constants/gs_errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
8 changes: 8 additions & 0 deletions lib/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
};

Expand Down
2 changes: 2 additions & 0 deletions lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
Loading

0 comments on commit 8f0b0f6

Please sign in to comment.