snowflake-sdk
Version:
Node.js driver for Snowflake
284 lines • 15.2 kB
JavaScript
;
const Logger = require('./../logger').default;
const authUtil = require('../authentication/authentication_util');
const { getFreePort, format } = require('../util');
const { withBrowserActionTimeout, writeToCache, removeFromCache, readCache, } = require('./authentication_util');
const querystring = require('querystring');
const open = require('open');
const Util = require('../util');
const AuthenticationTypes = require('./authentication_types');
const { coordinateAuth } = require('./auth_coordinator');
/**
* Creates an oauth authenticator.
*
* @param {Object} connectionConfig
* @param {Object} httpClient
*
* @returns {Object}
* @constructor
*/
function AuthOauthAuthorizationCode(connectionConfig, httpClient) {
const DEFAULT_REDIRECT_HOST = 'http://127.0.0.1';
const browserActionTimeout = connectionConfig.getBrowserActionTimeout();
let oauth;
let token;
const clientId = connectionConfig.getOauthClientId();
const clientSecret = connectionConfig.getOauthClientSecret();
const authorizationUrl = getAuthorizationUrl(connectionConfig);
const tokenUrl = authUtil.getTokenUrl(connectionConfig);
const accessTokenKey = authUtil.buildOauthAccessTokenCacheKey(authorizationUrl.host, connectionConfig.username, AuthenticationTypes.OAUTH_AUTHORIZATION_CODE);
const refreshTokenKey = authUtil.buildOauthRefreshTokenCacheKey(tokenUrl.host, connectionConfig.username, AuthenticationTypes.OAUTH_AUTHORIZATION_CODE);
/**
* Update JSON body with token.
* @param {JSON} body
* @returns {null}
*/
this.updateBody = function (body) {
if (token) {
body.data.TOKEN = token;
}
body.data.AUTHENTICATOR = AuthenticationTypes.OAUTH_AUTHENTICATOR;
body.data.CLIENT_ENVIRONMENT.OAUTH_TYPE = AuthenticationTypes.OAUTH_AUTHORIZATION_CODE;
};
this.loadOauth4webapi = async function () {
if (!oauth) {
oauth = await import('oauth4webapi');
}
};
this.authenticate = async function () {
globalThis.crypto ??= require('node:crypto').webcrypto;
token = await coordinateAuth(connectionConfig.host, connectionConfig.username, AuthenticationTypes.OAUTH_AUTHORIZATION_CODE, async () => {
const accessTokenFromCache = await readCache(accessTokenKey);
const refreshTokenFromCache = await readCache(refreshTokenKey);
if (accessTokenFromCache && connectionConfig.getClientStoreTemporaryCredential()) {
return accessTokenFromCache;
}
else if (refreshTokenFromCache && connectionConfig.getClientStoreTemporaryCredential()) {
try {
return await this.getAccessTokenUsingRefreshToken(refreshTokenFromCache);
}
catch (error) {
// A cached refresh token can be revoked, expired, or already
// consumed (single-use). Without eviction the bad token stays in
// the cache and every subsequent connect repeats this failure with
// no recovery path.
await removeFromCache(refreshTokenKey);
Logger().warn(format('Error while getting access token using cached refresh token. Message: %s. The refresh token is removed from cache - authentication will proceed from the beginning', error.message));
return this.executeFullAuthorizationCodeFlow();
}
}
else {
return this.executeFullAuthorizationCodeFlow();
}
});
};
// Wipes the cached access token. Called by the connection-failure dispatch
// in `sf.js` for every GS `success: false` response so we never resend a
// stale access token, regardless of whether a full reauthenticate follows.
this.clearAccessTokenCache = async function () {
await removeFromCache(accessTokenKey);
};
this.reauthenticate = async function (body) {
const refreshToken = await readCache(refreshTokenKey);
if (refreshToken) {
try {
token = await this.getAccessTokenUsingRefreshToken(refreshToken);
this.updateBody(body);
}
catch (error) {
await removeFromCache(refreshTokenKey);
Logger().warn(format('Error while getting access token using refresh token. Message: %s. The refresh token is removed form cache - authentication must be proceed from the beginning', error.message));
await this.authenticate();
this.updateBody(body);
}
}
else {
await this.authenticate();
this.updateBody(body);
}
};
this.executeFullAuthorizationCodeFlow = async function () {
await this.loadOauth4webapi(); // import module using the dynamic import
const codeChallengeMethod = connectionConfig.getOauthChallengeMethod() || 'S256'; // TODO: should be verified with "discovery" response
//An issuer is a obligatory parameter in validation processed by oauth4webapi library, even when it isn't used
const issuer = connectionConfig.issuer || 'UNKNOWN';
const codeVerifier = oauth.generateRandomCodeVerifier();
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
const as = { issuer: issuer };
// oxlint-disable-next-line camelcase
const client = { client_id: clientId };
const clientAuth = oauth.ClientSecretPost(clientSecret);
const redirectUri = await buildRedirectUri(connectionConfig);
const scope = await authUtil.prepareScope(connectionConfig);
const authorizationUrlWithParams = await prepareAuthorizationUrl(authorizationUrl, client, redirectUri, codeChallenge, codeChallengeMethod, as, scope);
const authorizationCodeResponse = await requestAuthorizationCode(authorizationUrlWithParams, browserActionTimeout);
const params = oauth.validateAuthResponse(as, client, authorizationUrlWithParams, authorizationCodeResponse.state);
params.set('code', authorizationCodeResponse.code);
Logger().trace('Requesting token');
const token = await requestToken(as, tokenUrl, client, clientAuth, params, redirectUri, codeVerifier);
return token;
};
this.getAccessTokenUsingRefreshToken = async function (refreshToken) {
globalThis.crypto ??= require('node:crypto').webcrypto;
await this.loadOauth4webapi(); // import module using the dynamic import
const issuer = connectionConfig.issuer || 'UNKNOWN';
const as = { issuer: issuer };
const clientId = connectionConfig.getOauthClientId();
const clientSecret = connectionConfig.getOauthClientSecret();
// oxlint-disable-next-line camelcase
const client = { client_id: clientId };
const clientAuth = oauth.ClientSecretPost(clientSecret);
// Refresh Token Grant Request & Response
const tokenUrl = authUtil.getTokenUrl(connectionConfig);
Logger().trace(`Receiving new OAuth access token from: Host: ${tokenUrl.host} Path: ${tokenUrl.pathname}`);
as['token_endpoint'] = tokenUrl.href;
const response = await oauth.refreshTokenGrantRequest(as, client, clientAuth, refreshToken, {
[oauth.allowInsecureRequests]: connectionConfig.getOauthHttpAllowed(),
[oauth.customFetch]: async (url, options) => await convertToResponseType(httpClient, url, options),
});
const result = await oauth.processRefreshTokenResponse(as, client, response);
if (result.access_token) {
//cache access token
Logger().debug(`Received new OAuth access token from: Host: ${tokenUrl.host} Path: ${tokenUrl.pathname}`);
await writeToCache(accessTokenKey, result.access_token);
//cache refreshToken if exists
if (result.refresh_token) {
//cache refresh token
Logger().debug(`Received new OAuth refresh token from: Host: ${tokenUrl.host} Path: ${tokenUrl.pathname}`);
await writeToCache(refreshTokenKey, result.refresh_token);
}
}
else {
throw Error(`Response doesn't contain OAuth access token. Requested URI: Host: ${tokenUrl.host} Path: ${tokenUrl.pathname}`);
}
return result.access_token;
};
async function prepareAuthorizationUrl(authorizationUrl, client, redirectUri, codeChallenge, codeChallengeMethod, as, scope) {
authorizationUrl.searchParams.set('client_id', client.client_id);
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
authorizationUrl.searchParams.set('response_type', 'code');
if (scope) {
authorizationUrl.searchParams.set('scope', scope);
}
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
authorizationUrl.searchParams.set('code_challenge_method', codeChallengeMethod);
/**
* We cannot be sure PKCE is supported then the state should be used.
*/
if (as.code_challenge_methods_supported?.includes('S256') !== true) {
const state = oauth.generateRandomState();
authorizationUrl.searchParams.set('state', state);
}
return authorizationUrl;
}
async function verifyPortIsAvailable(server, redirectPort) {
return Util.isPortOpen(redirectPort).catch((rejected) => {
server.close();
throw new Error(`Cannot run server using provided redirect url. ${rejected}`);
});
}
async function requestAuthorizationCode(authorizationUrl, browserActionTimeout) {
if (!Util.number.isPositiveInteger(browserActionTimeout)) {
throw new Error(`Invalid value for browser action timeout: ${browserActionTimeout}`);
}
let server;
const receiveData = new Promise((resolve, reject) => {
server = authUtil.createServer(resolve, reject, {
renderer: connectionConfig.browserResponseRenderer,
});
});
const redirectUri = new URL(authorizationUrl.searchParams.get('redirect_uri'));
await verifyPortIsAvailable(server, redirectUri.port);
try {
await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
server.listen(redirectUri.port || 0, 0);
});
}
catch (err) {
throw new Error(`Error while creating local server on port ${redirectUri.port}: ${err}`);
}
try {
const codeProvider = connectionConfig.openExternalBrowserCallback || open;
Logger().debug(`Opening your browser to obtain the authorization code: ${authorizationUrl.href}`);
const result = codeProvider(authorizationUrl.href);
if (result && typeof result.catch === 'function') {
// Fire and forget, the actual result handler is in codeResponse
// .catch() is used to avoid unhandled promise rejection warning
result.catch(() => { });
}
const codeResponse = await withBrowserActionTimeout(browserActionTimeout, receiveData).catch((rejected) => {
throw new Error(rejected);
});
const autorizationCodeResponseParameters = querystring.parse(codeResponse.substring(codeResponse.indexOf('?') + 1));
const code = autorizationCodeResponseParameters['code'];
const state = autorizationCodeResponseParameters['state'].replace(new RegExp('\\sHTTP/.*'), '');
Logger().debug(`Received new OAuth authorization code from: Host: ${authorizationUrl.host} Path: ${authorizationUrl.pathname}`);
return { code: code, state: state };
}
finally {
// createServer closes the server itself on its success / IdP-error
// paths; closing again here is a safe no-op and ensures we don't leak
// the listener on timeouts, browser-launch failures, or unexpected throws.
server.close();
}
}
async function convertToResponseType(httpClient, url, options) {
function asResponseType(response) {
return new Response(response.json, {
status: response.statusCode,
statusText: response.statusText,
headers: response.headers,
});
}
options.url = url;
return asResponseType(await httpClient.requestAsync(options));
}
async function requestToken(as, tokenUrl, client, clientAuth, params, redirectUri, codeVerifier) {
Logger().trace(`Receiving new OAuth access token from: Host: ${tokenUrl.host} Path: ${tokenUrl.pathname}`);
as['token_endpoint'] = tokenUrl.href;
const response = await oauth.authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, codeVerifier, {
[oauth.allowInsecureRequests]: connectionConfig.getOauthHttpAllowed(),
[oauth.customFetch]: async (url, options) => await convertToResponseType(httpClient, url, options),
additionalParameters: connectionConfig.oauthEnableSingleUseRefreshTokens
? {
// oxlint-disable-next-line camelcase
enable_single_use_refresh_tokens: 'true',
}
: undefined,
});
const result = await oauth.processAuthorizationCodeResponse(as, client, response);
if (result.access_token) {
//cache access token
Logger().debug(`Received new OAuth access token from: Host: ${tokenUrl.host} Path: ${tokenUrl.pathname}`);
await writeToCache(accessTokenKey, result.access_token);
//cache refreshToken if exists
if (result.refresh_token) {
//cache refresh token
Logger().debug(`Received new OAuth refresh token from: Host: ${tokenUrl.host} Path: ${tokenUrl.pathname}`);
await writeToCache(refreshTokenKey, result.refresh_token);
}
}
else {
throw Error(`Response doesn't contain OAuth access token. Requested URI: Host: ${tokenUrl.host} Path: ${tokenUrl.pathname}`);
}
return result.access_token;
}
function getAuthorizationUrl(options) {
const authCodeUrl = options.getOauthAuthorizationUrl();
Logger().debug(`Url used for receiving authorization code: ${authCodeUrl}`);
return new URL(authCodeUrl);
}
async function buildRedirectUri(options) {
const redirectUri = options.getOauthRedirectUri() || (await createDefaultRedirectUri());
Logger().debug(`Authorization code redirect URL: ${redirectUri}`);
return redirectUri;
}
async function createDefaultRedirectUri() {
const redirectPort = await getFreePort();
return `${DEFAULT_REDIRECT_HOST}:${redirectPort}`;
}
}
module.exports = AuthOauthAuthorizationCode;
//# sourceMappingURL=auth_oauth_authorization_code.js.map