dce-expresskit
Version:
Shared functions, helpers, and tools for Harvard DCE Express-based servers
320 lines (280 loc) • 8.89 kB
text/typescript
// Import dce-reactkit
import {
ErrorWithCode,
MINUTE_IN_MS,
} from 'dce-reactkit';
// Import oauth
import oauth from 'oauth-signature';
// Import crypto
import crypto from 'crypto';
// Import shared helpers
import { internalGetCrossServerCredentialCollection } from './initExpressKitCollections';
// Import shared types
import ExpressKitErrorCode from '../types/ExpressKitErrorCode';
import CrossServerCredential from '../types/CrossServerCredential';
/*------------------------------------------------------------------------*/
/* ------------------------------- Helpers ------------------------------ */
/*------------------------------------------------------------------------*/
/**
* Generate an oauth signature
* @author Gabe Abrams
* @param opts object containing all arguments
* @param opts.method the http method
* @param opts.path the http request path
* @param opts.params the data in the body to sign
* @param opts.secret the secret to sign with
* @return the signature
*/
const genSignature = async (
opts: {
method?: string,
path?: string,
params?: { [key: string]: any },
secret: string,
},
): Promise<string> => {
// Destructure opts
const {
method,
path,
params,
secret,
} = opts;
// Order the params alphabetically by key
const keys = Object.keys(params ?? {});
keys.sort();
const orderedParams: {
[key: string]: any,
} = {};
keys.forEach((key) => {
// Skip oauth_signature
if (key === 'oauth_signature') {
return;
}
// Add the param
orderedParams[key] = (params ?? {})[key];
});
// Generate the signature
return decodeURIComponent(oauth.generate(
method ?? 'GET',
path ?? 'no-path',
orderedParams,
secret,
));
};
/**
* Decrypt an encrypted string using a secret
* @author Gabe Abrams
* @param str the encrypted string
* @return the decrypted string
*/
const decrypt = async (
encryptedPack: string,
): Promise<string> => {
// Decryption process based on:
// https://medium.com/@tony.infisical/guide-to-nodes-crypto-module-for-encryption-decryption-65c077176980
// Get the encryption secret
const { DCEKIT_CRED_ENCODING_SALT } = process.env;
if (!DCEKIT_CRED_ENCODING_SALT) {
throw new ErrorWithCode(
'Could not decrypt a string because the encryption salt was not set.',
ExpressKitErrorCode.CrossServerNoCredentialEncodingSalt,
);
}
// Separate encrypted pack
const {
ciphertext,
iv,
tag,
} = JSON.parse(decodeURIComponent(encryptedPack));
// Parse the encrypted data
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
Buffer.from(DCEKIT_CRED_ENCODING_SALT, 'base64'),
Buffer.from(iv, 'base64'),
);
// Set the authentication tag
decipher.setAuthTag(Buffer.from(tag, 'base64'));
// Decrypt the string
let str = decipher.update(ciphertext, 'base64', 'utf8');
str += decipher.final('utf8');
// Return the decrypted string
return str;
};
/*------------------------------------------------------------------------*/
/* ------------------------------- Signing ------------------------------ */
/*------------------------------------------------------------------------*/
/**
* Sign a request and get the new request params
* @author Gabe Abrams
* @param opts object containing all arguments
* @param opts.method the method to sign
* @param opts.path the http request path
* @param opts.params the data in the body to sign
* @param opts.key the dcekit key to sign with
* @param opts.secret the dcekit secret to sign with
* @return augmented params for the request, including a signature, timestamp, and key
*/
export const signRequest = async (
opts: {
method: string,
path: string,
params: { [key: string]: any },
key: string,
secret: string,
},
): Promise<{ [key: string]: any }> => {
// Destructure opts
const method = opts.method.toUpperCase();
const {
path,
params,
key,
secret,
} = opts;
// Augment the params
const augmentedParams: {
[key: string]: any,
} = {
...params,
oauth_consumer_key: key,
oauth_nonce: Math.random().toString(36),
oauth_timestamp: Date.now(),
};
// Generate a signature
const signature = await genSignature({
method,
path,
params,
secret,
});
// Add signature to the augmented params
augmentedParams.oauth_signature = signature;
// Return the augmented params
return augmentedParams;
};
/**
* Validate a signed request. Throws an error if invalid
* @author Gabe Abrams
* @param opts object containing all arguments
* @param opts.method the method of the data validate
* @param opts.path the http request path to validate
* @param opts.scope the name of the scope to validate
* @param opts.params the request data to validate
* @returns parsed and validated params
*/
export const validateSignedRequest = async (
opts: {
method: string,
path: string,
scope: string,
params: { [key: string]: any },
},
) => {
/* ---------- Collect Info ---------- */
// Get the signature
if (!opts.params.oauth_signature) {
throw new ErrorWithCode(
'Could not validate a cross-server request there was no oauth signature.',
ExpressKitErrorCode.CrossServerMissingSignedRequestInfo,
);
}
const signature = opts.params.oauth_signature;
// Get the timestamp
if (
// No timestamp
!opts.params.oauth_timestamp
// Invalid timestamp
|| Number.isNaN(Number.parseInt(opts.params.oauth_timestamp, 10))
) {
throw new ErrorWithCode(
'Could not validate a cross-server request there was no valid oauth timestamp.',
ExpressKitErrorCode.CrossServerMissingSignedRequestInfo,
);
}
const timestamp = Number.parseInt(opts.params.oauth_timestamp, 10);
// Get the key
if (!opts.params.oauth_consumer_key) {
throw new ErrorWithCode(
'Could not validate a cross-server request there was no oauth consumer key.',
ExpressKitErrorCode.CrossServerMissingSignedRequestInfo,
);
}
const key = opts.params.oauth_consumer_key;
// Get the rest of the info
const {
method,
path,
params,
scope,
} = opts;
/* ------- Look Up Credential ------- */
// Get the cross-server credential collection
const crossServerCredentialCollection = await internalGetCrossServerCredentialCollection();
if (!crossServerCredentialCollection) {
throw new ErrorWithCode(
'Could not validate a cross-server request because the cross-server credential collection was not ready in time.',
ExpressKitErrorCode.SignedRequestInvalidCollection,
);
}
// Get the cross-server credential
const crossServerCredentialMatches: CrossServerCredential[] = await crossServerCredentialCollection.find({ key });
if (!crossServerCredentialMatches || crossServerCredentialMatches.length === 0) {
throw new ErrorWithCode(
'Could not validate a cross-server request because the credential was not found.',
ExpressKitErrorCode.SignedRequestInvalidCredential,
);
}
const crossServerCredential = crossServerCredentialMatches[0];
// Make sure the scope is included
const allowedScopes = crossServerCredential.scopes;
if (!allowedScopes || !Array.isArray(allowedScopes)) {
throw new ErrorWithCode(
'Could not validate a cross-server request because the credential does not have access to any scopes.',
ExpressKitErrorCode.SignedRequestInvalidScope,
);
}
if (!allowedScopes.includes(scope)) {
throw new ErrorWithCode(
'Could not validate a cross-server request because the required scope was not approved for the credential.',
ExpressKitErrorCode.SignedRequestInvalidScope,
);
}
// Decode the secret
const secret = await decrypt(crossServerCredential.encodedeSecret);
/* -------- Verify Signature -------- */
// Curate what goes into the params
const paramsToSign: {
[key: string]: any,
} = {
...params,
};
Object.keys(paramsToSign).forEach((key) => {
// Delete oauth params
if (key.startsWith('oauth_')) {
delete paramsToSign[key];
}
});
// Generate a new signature to compare
const expectedSignature = await genSignature({
method,
path,
params: paramsToSign,
secret,
});
// Make sure the signatures match
if (signature !== expectedSignature) {
throw new ErrorWithCode(
'Could not validate a cross-server request because the signature did not match.',
ExpressKitErrorCode.SignedRequestInvalidSignature,
);
}
// Make sure the timestamp was recent enough
const elapsedMs = Math.abs(Date.now() - timestamp);
if (elapsedMs > MINUTE_IN_MS) {
throw new ErrorWithCode(
'Could not validate a cross-server request because the request was too old.',
ExpressKitErrorCode.SignedRequestInvalidTimestamp,
);
}
};