oracle-nosqldb
Version:
Node.js driver for Oracle NoSQL Database
594 lines (524 loc) • 22.6 kB
JavaScript
/*-
* Copyright (c) 2018, 2024 Oracle and/or its affiliates. All rights reserved.
*
* Licensed under the Universal Permissive License v 1.0 as shown at
* https://oss.oracle.com/licenses/upl/
*/
const assert = require('assert');
const fsPromises = require('fs').promises;
const isPosInt32 = require('../../utils').isPosInt32;
const HttpConstants = require('../../constants').HttpConstants;
const ErrorCode = require('../../error_code');
const NoSQLArgumentError = require('../../error').NoSQLArgumentError;
const AuthError = require('../../error').NoSQLAuthorizationError;
const Utils = require('./utils');
const IAMProfileProvider = require('./profile').IAMProfileProvider;
const UserProfileProvider = require('./profile').UserProfileProvider;
const OCIConfigFileProvider = require('./profile').OCIConfigFileProvider;
const SessTokenProfileProvider = require('./profile')
.SessTokenProfileProvider;
const ResourcePrincipalProvider = require('./resource_principal');
const InstancePrincipalProvider = require('./instance_principal');
const OKEWorkloadIdentityProvider = require('./oke_workload');
let Config;
/* Maximum lifetime of signature 300 seconds */
const MAX_ENTRY_LIFE_TIME = 300;
const DATE_HEADER = Utils.isInBrowser ?
HttpConstants.X_DATE : HttpConstants.DATE;
const SIGNING_HEADERS = `(request-target) host ${DATE_HEADER}`;
const OBO_TOKEN_HEADER = 'opc-obo-token';
const CONTENT_HEADERS = 'content-length content-type x-content-sha256';
const PROFILE_PROPS = [ 'tenantId', 'userId', 'fingerprint', 'privateKey',
'privateKeyFile', 'passphrase' ];
const OCI_CONFIG_PROPS = [ 'configFile', 'profileName' ];
const CREDS_PROVIDER_PROP = 'credentialsProvider';
const INST_PRINCIPAL_PROP = 'useInstancePrincipal';
const IP_EXTRA_PROPS = [ 'federationEndpoint', 'delegationToken',
'delegationTokenProvider', 'delegationTokenFile' ];
const OKE_WORKLOAD_PROP = 'useOKEWorkloadIdentity';
const OKE_EXTRA_PROPS = [ 'serviceAccountToken',
'serviceAccountTokenFile', 'serviceAccountTokenProvider' ];
const RES_PRINCIPAL_PROP = 'useResourcePrincipal';
const RP_EXTRA_PROPS = [ 'useResourcePrincipalCompartment' ];
const SESS_TOKEN_PROP = 'useSessionToken';
const USER_IDEN_PROPS = [ ...PROFILE_PROPS, ...OCI_CONFIG_PROPS,
CREDS_PROVIDER_PROP ];
function _findProps(opt, props, truthyProps) {
let val;
if (props) {
val = props.find(item => opt[item] != null);
}
if (!val && truthyProps) {
val = truthyProps.find(item => opt[item]);
}
return val;
}
function _chkExclProps(cfg, opt, prop, props, truthyProps) {
const val = _findProps(opt, props, truthyProps);
if (!val) {
return;
}
if (Array.isArray(prop)) {
prop = 'any of ' + prop.join(', ');
}
throw new NoSQLArgumentError(
`Cannot specify property ${val} together with ${prop}`, cfg);
}
class IAMAuthorizationProvider {
constructor(opt, cfg) {
if (opt == null) {
//Possible if using default OCI config file with default profile.
opt = {};
} else if (typeof opt !== 'object') {
throw new NoSQLArgumentError('Invalid IAMConfig object', cfg);
}
//Needed in case this provider is created outside NoSQLClient
//instance. Note that this is currently sufficient, because
//AuthConfig.defaults.iam has no nested properties. Otherwise the code
//below will need to change to use Config.inheritOpt().
opt.__proto__ = IAMAuthorizationProvider.configDefaults;
if (opt.durationSeconds == null) {
this._duration = MAX_ENTRY_LIFE_TIME;
} else {
if (!isPosInt32(opt.durationSeconds)) {
throw new NoSQLArgumentError(
'Invalid auth.iam.durationSeconds value', cfg);
}
if (opt.durationSeconds > MAX_ENTRY_LIFE_TIME) {
throw new NoSQLArgumentError(`Signature cannot be cached for \
more than ${MAX_ENTRY_LIFE_TIME} seconds`, cfg);
}
this._duration = opt.durationSeconds;
}
this._duration *= 1000;
if (opt.refreshAheadMs != null) {
if (!isPosInt32(opt.refreshAheadMs)) {
throw new NoSQLArgumentError(
'Invalid auth.iam.refreshAheadMs value', cfg);
}
if (this._duration > opt.refreshAheadMs) {
this._refreshInterval = this._duration - opt.refreshAheadMs;
}
}
let prop;
//init authentication details provider
if (opt.useResourcePrincipal) {
_chkExclProps(cfg, opt, RES_PRINCIPAL_PROP, USER_IDEN_PROPS,
[ INST_PRINCIPAL_PROP, OKE_WORKLOAD_PROP, SESS_TOKEN_PROP ]);
this._provider = new ResourcePrincipalProvider(opt, cfg);
} else if ((prop = _findProps(opt, null, RP_EXTRA_PROPS)) != null) {
throw new NoSQLArgumentError(`Cannot specify property ${prop} \
without ${RES_PRINCIPAL_PROP}`, cfg);
} else if (opt.useInstancePrincipal) {
_chkExclProps(cfg, opt, INST_PRINCIPAL_PROP, USER_IDEN_PROPS,
[ OKE_WORKLOAD_PROP, SESS_TOKEN_PROP ]);
this._provider = new InstancePrincipalProvider(opt, cfg);
} else if ((prop = _findProps(opt, IP_EXTRA_PROPS)) != null) {
throw new NoSQLArgumentError(`Cannot specify property ${prop} \
without ${INST_PRINCIPAL_PROP}`, cfg);
} else if (opt.useOKEWorkloadIdentity) {
_chkExclProps(cfg, opt, OKE_WORKLOAD_PROP, USER_IDEN_PROPS,
[ SESS_TOKEN_PROP ]);
this._provider = new OKEWorkloadIdentityProvider(opt, cfg);
} else if ((prop = _findProps(opt, OKE_EXTRA_PROPS)) != null) {
throw new NoSQLArgumentError(`Cannot specify property ${prop} \
without ${OKE_WORKLOAD_PROP}`, cfg);
} else if (opt.useSessionToken) {
_chkExclProps(cfg, opt, SESS_TOKEN_PROP,
[ ...PROFILE_PROPS, CREDS_PROVIDER_PROP ]);
this._provider = new OCIConfigFileProvider(opt, cfg,
SessTokenProfileProvider);
} else if (PROFILE_PROPS.some(prop => opt[prop] != null)) {
_chkExclProps(cfg, opt, PROFILE_PROPS,
[ ...OCI_CONFIG_PROPS, CREDS_PROVIDER_PROP ]);
this._provider = new IAMProfileProvider(opt, cfg);
} else if (opt[CREDS_PROVIDER_PROP] != null) {
_chkExclProps(cfg, opt, CREDS_PROVIDER_PROP, OCI_CONFIG_PROPS);
this._provider = new UserProfileProvider(opt, cfg);
} else if (opt.profileProvider != null) {
//profileProvider is only used internally now, so not included in
//exclusivity checks above
if (typeof opt.profileProvider !== 'object') {
throw new NoSQLArgumentError(
'Custom profile provider must be an object', cfg);
}
this._provider = opt.profileProvider;
} else {
this._provider = new OCIConfigFileProvider(opt, cfg);
}
this._initDelegationToken(opt, cfg);
this._signature = null;
this._refreshTimer = null;
}
_initDelegationToken(opt, cfg) {
if (opt.delegationToken != null) {
assert(opt.useInstancePrincipal);
if (opt.delegationTokenProvider != null ||
opt.delegationTokenFile != null) {
throw new NoSQLArgumentError('Cannot specify \
auth.iam.delegationToken together with auth.iam.delegationTokenProvider or \
auth.iam.delegationTokenFile', cfg);
}
if (typeof opt.delegationToken !== 'string' ||
!opt.delegationToken) {
throw new NoSQLArgumentError('Invalid value for \
auth.iam.delegationToken, must be non-empty string', cfg);
}
this._delegationToken = opt.delegationToken;
} else if (opt.delegationTokenProvider != null) {
assert(opt.useInstancePrincipal);
if (opt.delegationTokenFile != null) {
throw new NoSQLArgumentError('Cannot specify \
auth.iam.delegationTokenProvider together with auth.iam.delegationTokenFile',
cfg);
}
if (typeof opt.delegationTokenProvider === 'string') {
if (!opt.delegationTokenProvider) {
throw new NoSQLArgumentError('Invalid value of \
auth.iam.delegationTokenProvider, cannot be empty string', cfg);
}
this._delegationTokenFile = opt.delegationTokenProvider;
} else if (typeof opt.delegationTokenProvider === 'object') {
if (typeof opt.delegationTokenProvider.loadDelegationToken !==
'function') {
throw new NoSQLArgumentError('Invalid value of \
auth.iam.delegationTokenProvider: does not contain loadDelegationToken \
method', cfg);
}
this._delegationTokenProvider = opt.delegationTokenProvider;
} else if (typeof opt.delegationTokenProvider === 'function') {
this._delegationTokenProvider = {
loadDelegationToken: opt.delegationTokenProvider
};
} else {
throw new NoSQLArgumentError(`Invalid type of \
auth.iam.delegationTokenProvider: ${typeof opt.delegationTokenProvider}`,
cfg);
}
} else if (opt.delegationTokenFile != null) {
assert(opt.useInstancePrincipal);
if (typeof opt.delegationTokenFile !== 'string' ||
!opt.delegationTokenFile) {
throw new NoSQLArgumentError('Invalid value for \
auth.iam.delegationTokenFile, must be non-empty string', cfg);
}
this._delegationTokenFile = opt.delegationTokenFile;
}
if (this._delegationTokenFile) {
assert(this._delegationTokenProvider == null);
this._delegationTokenProvider = {
loadDelegationToken: async () => {
const data = await fsPromises.readFile(
this._delegationTokenFile, 'utf8');
return data.replace(/\r?\n/g, '');
}
};
}
}
async _loadDelegationToken()
{
let delegationToken;
try {
delegationToken = await this._delegationTokenProvider
.loadDelegationToken();
} catch(err) {
throw AuthError.invalidArg('Error retrieving delegation token' +
this._delegationTokenFile ?
` from file ${this._delegationTokenFile}` : '', err);
}
if (typeof delegationToken !== 'string' || !delegationToken) {
throw AuthError.invalidArg('Retrieved delegation token \
is invalid or empty');
}
return delegationToken;
}
_signingHeaders(withContent) {
let res = SIGNING_HEADERS;
if (withContent) {
res += ' ' + CONTENT_HEADERS;
}
if (this._delegationToken != null) {
res += ' ' + OBO_TOKEN_HEADER;
}
return res;
}
//The order of headers in _signingHeaders() and _signingContent() should
//match.
_signingContent(dateStr, reqSigning) {
let content = `${HttpConstants.REQUEST_TARGET}: post /\
${HttpConstants.NOSQL_DATA_PATH}\n\
${HttpConstants.HOST}: ${this._serviceHost}\n\
${DATE_HEADER}: ${dateStr}`;
if (reqSigning) {
content +=
`\n${HttpConstants.CONTENT_LENGTH_LWR}: ${reqSigning.len}\n\
${HttpConstants.CONTENT_TYPE_LWR}: ${reqSigning.type}\n\
${HttpConstants.CONTENT_SHA256}: ${reqSigning.digest}`;
}
if (this._delegationToken != null) {
content += `\n${OBO_TOKEN_HEADER}: ${this._delegationToken}`;
}
return content;
}
async _createSignatureDetails(needProfileRefresh, req) {
this._profile = await this._provider.getProfile(needProfileRefresh);
assert(this._profile != null);
if (this._delegationTokenProvider != null) {
this._delegationToken = await this._loadDelegationToken();
}
const reqSigning = (req && req._op.needsContentSigned()) ? {
type: req._protoMgr.contentType,
len: req._protoMgr.getContentLength(req._buf),
digest: Utils.sha256digest(req._protoMgr.getContent(req._buf))
} : undefined;
const date = new Date();
const dateStr = date.toUTCString();
let signature = await Utils.sign(
this._signingContent(dateStr, reqSigning),
this._profile.privateKey, 'request');
return {
time: date.getTime(),
dateStr,
header: Utils.signatureHeader(this._signingHeaders(reqSigning),
this._profile.keyId, signature),
tenantId: this._profile.tenantId,
compartmentId: this._profile.compartmentId,
digest: reqSigning ? reqSigning.digest : undefined
};
}
_scheduleRefresh() {
if (this._refreshInterval) {
//_createSignatureDetails may be called again before
//the token expiration due to INVALID_AUTHORIZATION error,
//so the timer may be already set
if (this._refreshTimer != null) {
clearTimeout(this._refreshTimer);
}
this._refreshTimer = setTimeout(
() => this._refreshSignatureDetails(),
this._refreshInterval);
}
}
async _refreshSignatureDetails() {
try {
this._signatureDetails = await this._createSignatureDetails();
} catch(err) {
//This promise rejection will not be handled so we don't rethrow
//but only log the error somehow and return without rescheduling.
//The user will get the error when _createSignatureDetails() is
//called again by getAuthorization().
return;
}
this._scheduleRefresh();
}
onInit(cfg) {
if (cfg.compartment != null &&
(typeof cfg.compartment !== 'string' || !cfg.compartment)) {
throw new NoSQLArgumentError(
`Invalid value of compartment: ${cfg.compartment}`);
}
//Special case for cloud where the region may be specified in OCI
//config file or as part of resource principal environment. In this
//case we try to get the region from the auth provider and retry
//getting the url from this region.
if (cfg.url == null) {
if (this._provider.getRegion != null) {
cfg.region = this._provider.getRegion();
}
//We have to load Config dynamicaly to avoid circular dependency.
if (Config == null) {
Config = require('../../config');
}
//If the provider above does not have getRegion() function, this
//will retult in NoSQLArgumentError.
Config.initUrl(cfg, true);
}
this._serviceHost = cfg.url.hostname;
}
/**
* Gets authorization object for given database operation.
* Authorization object contains required authorization properties.
* A local cached value will be returned most of the time.
* @implements {getAuthorization}
* @see {@link getAuthorization}
* @param {Operation} op Database operation
* needing AT
* @returns {Promise} Promise of authorization object
*/
async getAuthorization(op) {
assert(op != null);
const invalidAuth = op.lastError != null &&
op.lastError.errorCode === ErrorCode.INVALID_AUTHORIZATION;
let signatureDetails;
assert(op._op != null);
const isContentSigned = op._op.needsContentSigned();
if (isContentSigned) {
//For requests that need their content signed, we cannot cache the
//signature (because the signature is different for every
//request).
signatureDetails = await this._createSignatureDetails(
invalidAuth, op);
} else {
const invalidProfile = invalidAuth || this._profile == null ||
(this._provider.isProfileValid != null &&
!this._provider.isProfileValid(this._profile));
//Update cached signature if needed.
if (invalidProfile || this._signatureDetails == null ||
this._signatureDetails.time < Date.now() - this._duration) {
this._signatureDetails =
await this._createSignatureDetails(invalidAuth);
this._scheduleRefresh();
}
signatureDetails = this._signatureDetails;
}
const ret = {
[HttpConstants.AUTHORIZATION]: signatureDetails.header,
[DATE_HEADER]: signatureDetails.dateStr,
};
if (isContentSigned) {
ret[HttpConstants.CONTENT_SHA256] = signatureDetails.digest;
}
//It is possible that if _createSignatureDetails() is called
//concurrently and there is a new delegation token, at some moment we
//could have new delegation token and old signature. However, this
//would be very rare and if happens the request will fail with an auth
//error and be retried, at which time a new signature will be created.
if (this._delegationToken != null) {
ret[OBO_TOKEN_HEADER] = this._delegationToken;
}
/*
* If request doesn't have compartment id, first check if using
* resource principal and useResourcePrincipalCompartment is set to
* true, in which case we will use the resource compartment as default
* compartment. Otherwise, set the tenant id as the default
* compartment, which is the root compartment in IAM if using user
* principal.
*/
let compartment = op.opt.compartment;
if (compartment == null) {
//Available when using useResourcePrincipalCompartment option.
compartment = signatureDetails.compartmentId;
//Otherwise use tenant id if available.
if (compartment == null) {
compartment = signatureDetails.tenantId;
}
}
if (compartment != null) {
if (typeof compartment !== 'string' || !compartment) {
throw new NoSQLArgumentError(`Invalid value of \
opt.compartment: "${compartment}"`);
}
ret[HttpConstants.COMPARTMENT_ID] = compartment;
}
//Currently proxy uses the presence of this header to identify
//requests from the browser and thus enable CORS (by sending back
//Access-Control-Allow-Origin header). The value of this header is
//not currently used.
if (Utils.isInBrowser) {
ret[HttpConstants.OPC_REQUEST_ID] = 1;
}
return ret;
}
get region() {
return this._region;
}
async getResourcePrincipalClaims() {
if (!(this._provider instanceof ResourcePrincipalProvider)) {
return;
}
return this._provider.getRPSTClaims();
}
getRegion() {
if (this._provider.getRegion != null) {
return Promise.resolve(this._provider.getRegion());
}
if (this._provider.getRegionFromIMDS != null) {
return this._provider.getRegionFromIMDS();
}
}
//used in unit tests
clearCache() {
this._profile = null;
this._signatureDetails = null;
}
/**
* Releases resources associated with this provider.
* @see {@link AuthorizationProvider}
*/
async close() {
if (this._provider.close != null) {
this._provider.close();
}
if (this._refreshTimer != null) {
clearTimeout(this._refreshTimer);
this._refreshTimer = null;
}
}
static withInstancePrincipal(federationEndpoint) {
return new IAMAuthorizationProvider({
useInstancePrincipal: true,
federationEndpoint
});
}
static withInstancePrincipalForDelegation(delegationTokenOrProvider,
federationEndpoint) {
return new IAMAuthorizationProvider(Object.assign({
useInstancePrincipal: true,
federationEndpoint
}, typeof delegationTokenOrProvider === 'string' ?
{ delegationToken: delegationTokenOrProvider } :
{ delegationTokenProvider: delegationTokenOrProvider }));
}
static withInstancePrincipalForDelegationFromFile(delegationTokenFile,
federationEndpoint) {
return new IAMAuthorizationProvider({
useInstancePrincipal: true,
delegationTokenFile,
federationEndpoint
});
}
static withResourcePrincipal(useResourcePrincipalCompartment) {
return new IAMAuthorizationProvider({
useResourcePrincipal: true,
useResourcePrincipalCompartment
});
}
static withOKEWorkloadIdentity(serviceAccountToken) {
return new IAMAuthorizationProvider(
typeof serviceAccountToken === 'string' ? {
useOKEWorkloadIdentity: true,
serviceAccountToken
} : {
useOKEWorkloadIdentity: true,
serviceAccountTokenProvider: serviceAccountToken
});
}
static withOKEWorkloadIdentityAndTokenFile(serviceAccountTokenFile) {
return new IAMAuthorizationProvider({
useOKEWorkloadIdentity: true,
serviceAccountTokenFile
});
}
static withSessionToken(configFile, profileName) {
//1-argument overload
if (configFile !== undefined && profileName === undefined) {
profileName = configFile;
configFile = undefined;
}
return new IAMAuthorizationProvider({
useSessionToken: true,
configFile,
profileName
});
}
}
IAMAuthorizationProvider.configDefaults = Object.freeze({
timeout: 120000,
durationSeconds: MAX_ENTRY_LIFE_TIME,
refreshAheadMs: 10000,
//The below properties are not exposed to the user but different
//values are used in tests.
securityTokenRefreshAheadMs: 15000,
securityTokenExpireBeforeMs: 10000
});
module.exports = IAMAuthorizationProvider;