Skip to content

Commit

Permalink
SNOW-1825478- Support for Oauth PAT
Browse files Browse the repository at this point in the history
  • Loading branch information
sfc-gh-pmotacki committed Mar 5, 2025
1 parent 556e783 commit 5044a00
Show file tree
Hide file tree
Showing 20 changed files with 406 additions and 8 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ jobs:
nodeVersion: ['18.x', '20.x', '22.x']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
java-package: 'jre'
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.nodeVersion }}
Expand Down
2 changes: 1 addition & 1 deletion ci/_init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export DRIVER_NAME=nodejs
BUILD_IMAGE_VERSION=1

# Test Images
TEST_IMAGE_VERSION=1
TEST_IMAGE_VERSION=2

declare -A BUILD_IMAGE_NAMES=(
[$DRIVER_NAME-chainguard-node18]=$DOCKER_REGISTRY_NAME/client-$DRIVER_NAME-chainguard-node18-build:$BUILD_IMAGE_VERSION
Expand Down
5 changes: 4 additions & 1 deletion ci/container/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
},
"devDependencies": {
"async": "^3.2.3",
"https-proxy-agent": "^7.0.2",
"mocha": "^10.2.0",
"mock-require": "^3.0.3",
"nyc": "^15.1.0",
"test-console": "^2.0.0"
"test-console": "^2.0.0",
"wiremock": "^3.10.0",
"wiremock-rest-client": "^1.11.0"
},
"author": "Snowflake, Inc.",
"license": "ISC"
Expand Down
4 changes: 3 additions & 1 deletion ci/image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ FROM $IMAGE

USER root

RUN apk update && apk add python3 jq aws-cli gosu
RUN apk update && apk add python3 python3-dev py3-pip jq aws-cli gosu openjdk-17
RUN pip install -U snowflake-connector-python

ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk

# workspace
RUN mkdir -p /home/user && \
chmod 777 /home/user
Expand Down
2 changes: 1 addition & 1 deletion ci/test_authentication.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ docker run \
-v $(cd $THIS_DIR/.. && pwd):/mnt/host \
-v $WORKSPACE:/mnt/workspace \
--rm \
nexus.int.snowflakecomputing.com:8086/docker/snowdrivers-test-external-browser:3 \
nexus.int.snowflakecomputing.com:8086/docker/snowdrivers-test-external-browser:7 \
"/mnt/host/ci/container/test_authentication.sh"
29 changes: 29 additions & 0 deletions lib/authentication/auth_oauth_pat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const Util = require('../util');
/**
* Creates an oauth PAT authenticator.
*
* @param {String} token
* @param {String} password
*
* @returns {Object}
* @constructor
*/
function AuthOauthPAT(token, password) {
/**
* Update JSON body with token.
*
* @param {JSON} body
*
* @returns {null}
*/
this.updateBody = function (body) {
if (Util.exists(token)) {
body['data']['TOKEN'] = token;
} else if (Util.exists(password)) {
body['data']['TOKEN'] = password;
}
};

this.authenticate = async function () {};
}
module.exports = AuthOauthPAT;
6 changes: 4 additions & 2 deletions lib/authentication/authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const AuthDefault = require('./auth_default');
const AuthWeb = require('./auth_web');
const AuthKeypair = require('./auth_keypair');
const AuthOauth = require('./auth_oauth');
const AuthOauthPAT = require('./auth_oauth_pat');
const AuthOkta = require('./auth_okta');
const AuthIDToken = require('./auth_idtoken');
const Logger = require('../logger');
Expand Down Expand Up @@ -58,7 +59,6 @@ exports.formAuthJSON = function formAuthJSON(
*/
exports.getAuthenticator = function getAuthenticator(connectionConfig, httpClient) {
const authType = connectionConfig.getAuthenticator();
const openExternalBrowserCallback = connectionConfig.openExternalBrowserCallback; // Important for SSO in the Snowflake VS Code extension
let auth;
if (authType === AuthenticationTypes.DEFAULT_AUTHENTICATOR || authType === AuthenticationTypes.USER_PWD_MFA_AUTHENTICATOR) {
auth = new AuthDefault(connectionConfig);
Expand All @@ -70,8 +70,10 @@ exports.getAuthenticator = function getAuthenticator(connectionConfig, httpClien
}
} else if (authType === AuthenticationTypes.KEY_PAIR_AUTHENTICATOR) {
auth = new AuthKeypair(connectionConfig);
} else if (authType === AuthenticationTypes.OAUTH_AUTHENTICATOR) {
} else if (authType === AuthenticationTypes.OAUTH_AUTHENTICATOR ) {
auth = new AuthOauth(connectionConfig.getToken());
} else if (authType === AuthenticationTypes.PROGRAMMATIC_ACCESS_TOKEN ) {
auth = new AuthOauthPAT(connectionConfig.getToken(), connectionConfig.password);
} else if (this.isOktaAuth(authType)) {
auth = new AuthOkta(connectionConfig, httpClient);
} else {
Expand Down
1 change: 1 addition & 0 deletions lib/authentication/authentication_types.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const AuthenticationTypes =
OAUTH_AUTHENTICATOR: 'OAUTH',
USER_PWD_MFA_AUTHENTICATOR: 'USERNAME_PASSWORD_MFA',
ID_TOKEN_AUTHENTICATOR: 'ID_TOKEN',
PROGRAMMATIC_ACCESS_TOKEN: 'PROGRAMMATIC_ACCESS_TOKEN'
};

module.exports = AuthenticationTypes;
19 changes: 18 additions & 1 deletion lib/connection/connection_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ function ConnectionConfig(options, validateCredentials, qaMode, clientInfo) {
// username is not required for oauth and external browser authenticators
if (!Util.exists(options.authenticator) ||
(options.authenticator.toUpperCase() !== AuthenticationTypes.OAUTH_AUTHENTICATOR &&
options.authenticator.toUpperCase() !== AuthenticationTypes.EXTERNAL_BROWSER_AUTHENTICATOR)) {
options.authenticator.toUpperCase() !== AuthenticationTypes.EXTERNAL_BROWSER_AUTHENTICATOR &&
options.authenticator.toUpperCase() !== AuthenticationTypes.PROGRAMMATIC_ACCESS_TOKEN)) {
// check for missing username
Errors.checkArgumentExists(Util.exists(options.username),
ErrorCodes.ERR_CONN_CREATE_MISSING_USERNAME);
Expand All @@ -188,6 +189,22 @@ function ConnectionConfig(options, validateCredentials, qaMode, clientInfo) {
Errors.checkArgumentValid(Util.isString(options.password),
ErrorCodes.ERR_CONN_CREATE_INVALID_PASSWORD);
}
if (!Util.exists(options.authenticator) ||
options.authenticator === AuthenticationTypes.PROGRAMMATIC_ACCESS_TOKEN) {
// PASSWORD or TOKEN is needed
Errors.checkArgumentExists(Util.exists(options.password) || Util.exists(options.token),
ErrorCodes.ERR_CONN_CREATE_MISSING_PASSWORD);

if (Util.exists(options.password)) {
// check for invalid password
Errors.checkArgumentValid(Util.isString(options.password),
ErrorCodes.ERR_CONN_CREATE_INVALID_PASSWORD);
}
if (Util.exists(options.token)) {
Errors.checkArgumentValid(Util.isString(options.token),
ErrorCodes.ERR_CONN_CREATE_INVALID_OAUTH_TOKEN);
}
}

consolidateHostAndAccount(options);
}
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 @@ -80,6 +80,8 @@ exports[404053] = 'A host must be specified.';
exports[404054] = 'Invalid host. The specified value must be a string.';
exports[404055] = 'Invalid passcodeInPassword. The specified value must be a boolean';
exports[404056] = 'Invalid passcode. The specified value must be a string';
exports[404057] = 'A password or token must be specified.';


// 405001
exports[405001] = 'Invalid callback. The specified value must be a function.';
Expand Down
2 changes: 2 additions & 0 deletions lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ codes.ERR_CONN_CREATE_MISSING_HOST = 404053;
codes.ERR_CONN_CREATE_INVALID_HOST = 404054;
codes.ERR_CONN_CREATE_INVALID_PASSCODE_IN_PASSWORD = 404055;
codes.ERR_CONN_CREATE_INVALID_PASSCODE = 404056;
codes.ERR_CONN_CREATE_MISSING_PASSWORD_AND_TOKEN = 404057;


// 405001
codes.ERR_CONN_CONNECT_INVALID_CALLBACK = 405001;
Expand Down
13 changes: 13 additions & 0 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const os = require('os');
const Logger = require('./logger');
const fs = require('fs');
const Errors = require('./errors');
const net = require('net');

/**
* Note: A simple wrapper around util.inherits() for now, but this might change
Expand Down Expand Up @@ -765,6 +766,18 @@ exports.isWindows = function () {
return os.platform() === 'win32';
};


exports.getFreePort = async function () {
return new Promise(res => {
const srv = net.createServer();
srv.listen(0, () => {
const port = srv.address().port;
srv.close(() => res(port));
});
});
};


/**
* Left strip the specified character from a string.
*
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@
"mocha": "^10.2.0",
"mock-require": "^3.0.3",
"nyc": "^15.1.0",
"test-console": "^2.0.0"
"test-console": "^2.0.0",
"wiremock": "^3.10.0",
"wiremock-rest-client": "^1.11.0"
},
"peerDependencies": {
"asn1.js": "^5.4.1"
Expand Down
12 changes: 12 additions & 0 deletions test/authentication/connectionParameters.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ const keypairEncryptedPrivateKeyPath =
authenticator: 'SNOWFLAKE_JWT'
};

const oauthPATOnWiremock =
{
...baseParameters,
accessUrl: null,
username: 'MOCK_USERNAME',
account: 'MOCK_ACCOUNT_NAME',
host: 'localhost',
protocol: 'http',
authenticator: 'PROGRAMMATIC_ACCESS_TOKEN',
};

exports.externalBrowser = externalBrowser;
exports.okta = okta;
exports.oauth = oauth;
Expand All @@ -93,3 +104,4 @@ exports.snowflakeAuthTestOauthClientSecret = snowflakeAuthTestOauthClientSecret;
exports.snowflakeAuthTestOauthUrl = snowflakeAuthTestOauthUrl;
exports.snowflakeAuthTestPrivateKeyPath = snowflakeAuthTestPrivateKeyPath;
exports.snowflakeAuthTestInvalidPrivateKeyPath = snowflakeAuthTestInvalidPrivateKeyPath;
exports.oauthPATOnWiremock = oauthPATOnWiremock;
51 changes: 51 additions & 0 deletions test/integration/wiremock/testOauthPat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const net = require('net');
const connParameters = require('../../authentication/connectionParameters');
const AuthTest = require('../../authentication/authTestsBaseClass');
const { runWireMockAsync, addWireMockMappingsFromFile, } = require('../../wiremockRunner');
const os = require('os');
const { getFreePort } = require('../../../lib/util');

if (os.platform !== 'win32') {
describe('Oauth PAT authentication', function () {
let port;
let authTest;
let wireMock;
before(async () => {
port = await getFreePort();
wireMock = await runWireMockAsync(port);
});
beforeEach(async () => {
authTest = new AuthTest();
});
afterEach(async () => {
wireMock.scenarios.resetAllScenarios();
});
after(async () => {
await wireMock.global.shutdown();
});

it('Successful flow scenario PAT as token', async function () {
await addWireMockMappingsFromFile(wireMock, 'wiremock/mappings/pat/successful_flow.json');
const connectionOption = { ...connParameters.oauthPATOnWiremock, token: 'MOCK_TOKEN', port: port };
authTest.createConnection(connectionOption);
await authTest.connectAsync();
authTest.verifyNoErrorWasThrown();
});

it('Successful flow scenario PAT as password', async function () {
await addWireMockMappingsFromFile(wireMock, 'wiremock/mappings/pat/successful_flow.json');
const connectionOption = { ...connParameters.oauthPATOnWiremock, password: 'MOCK_TOKEN', port: port };
authTest.createConnection(connectionOption);
await authTest.connectAsync();
authTest.verifyNoErrorWasThrown();
});

it('Invalid token', async function () {
await addWireMockMappingsFromFile(wireMock, 'wiremock/mappings/pat/invalid_pat_token.json');
const connectionOption = { ...connParameters.oauthPATOnWiremock, token: 'INVALID_TOKEN', port: port };
authTest.createConnection(connectionOption);
await authTest.connectAsync();
authTest.verifyErrorWasThrown('Programmatic access token is invalid.');
});
});
}
43 changes: 43 additions & 0 deletions test/integration/wiremock/testWiremockRunner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const assert = require('assert');
const fs = require('fs');
const net = require('net');
const axios = require('axios');
const { runWireMockAsync } = require('../../wiremockRunner');
const os = require('os');

async function getFreePort() {
return new Promise(res => {
const srv = net.createServer();
srv.listen(0, () => {
const port = srv.address().port;
srv.close(() => res(port));
});
});
}

if (os.platform !== 'win32') {
describe('Wiremock test', function () {
let port, wireMock;
before(async () => {
port = await getFreePort();
wireMock = await runWireMockAsync(port);
});
after(async () => {
await wireMock.global.shutdown();
});
it('Run Wiremock instance, wait, verify connection and shutdown', async function () {
assert.doesNotReject(async () => await wireMock.mappings.getAllMappings());
});
it('Add mappings', async function () {
const requests = JSON.parse(fs.readFileSync('wiremock/mappings/testMapping.json', 'utf8'));
for (const mapping of requests.mappings) {
await wireMock.mappings.createMapping(mapping);
}
const mappings = await wireMock.mappings.getAllMappings();
assert.strictEqual(mappings.mappings.length, 2);
const response = await axios.get(`http://localhost:${port}/test/authorize.html`);
assert.strictEqual(response.status, 200);
});
});

}
58 changes: 58 additions & 0 deletions test/wiremockRunner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const WireMockRestClient = require('wiremock-rest-client').WireMockRestClient;
const { exec } = require('child_process');
const Logger = require('../lib/logger');
const fs = require('fs');


async function runWireMockAsync(port) {
let timeoutHandle;
const waitingWireMockPromise = new Promise( (resolve, reject) => {
try {
exec(`npx wiremock --enable-browser-proxying --proxy-pass-through false --port ${port} `);
const wireMock = new WireMockRestClient(`http://localhost:${port}`, { logLevel: 'trace' });
const readyWireMock = waitForWiremockStarted(wireMock);
resolve(readyWireMock);
} catch (err) {
reject(err);
}
});

const timeout = new Promise((resolve, reject) =>
timeoutHandle = setTimeout(
() => reject('Wiremock unavailable after 30s.'),
30000));
return Promise.race([waitingWireMockPromise, timeout])
.then(result => {
clearTimeout(timeoutHandle);
return result;
});
}

async function waitForWiremockStarted(wireMock) {
return fetch(wireMock.baseUri)
.then(async (resp) => {
if (resp.ok) {
return Promise.resolve(wireMock);
} else {
await new Promise(resolve => setTimeout(resolve, 1000));
Logger.getInstance().info(`Retry connection to WireMock after wrong response status: ${resp.status}`);
return await waitForWiremockStarted(wireMock);
}
})
.catch(async (err) => {
await new Promise(resolve => setTimeout(resolve, 1000));
Logger.getInstance().info(`Retry connection to WireMock after error: ${err}`);
return await waitForWiremockStarted(wireMock);
});
}

async function addWireMockMappingsFromFile(wireMock, filePath) {
const requests = JSON.parse(fs.readFileSync(filePath, 'utf8'));
for (const mapping of requests.mappings) {
await wireMock.mappings.createMapping(mapping);
}
}

exports.runWireMockAsync = runWireMockAsync;
exports.addWireMockMappingsFromFile = addWireMockMappingsFromFile;

Loading

0 comments on commit 5044a00

Please sign in to comment.