@axway/amplify-sdk
Version:
Axway Amplify SDK for Node.js
533 lines (487 loc) • 20.1 kB
JavaScript
import errors from './errors.js';
import Authenticator from './authenticators/authenticator.js';
import ClientSecret from './authenticators/client-secret.js';
import OwnerPassword from './authenticators/owner-password.js';
import PKCE from './authenticators/pkce.js';
import SignedJWT from './authenticators/signed-jwt.js';
import FileStore from './stores/file-store.js';
import MemoryStore from './stores/memory-store.js';
import SecureStore from './stores/secure-store.js';
import TokenStore from './stores/token-store.js';
import getEndpoints from './endpoints.js';
import snooplogg from 'snooplogg';
import { r as resolve } from './environments-C3ppEMBw.js';
import * as request from '@axway/amplify-request';
import 'ejs';
import 'fs-extra';
import 'jws';
import 'open';
import 'path';
import './server.js';
import 'crypto';
import 'get-port';
import 'http';
import 'url';
import './util.js';
import 'fs';
import '@axway/amplify-utils';
import 'uuid';
import 'keytar';
import 'pluralize';
const { log, warn } = snooplogg('amplify-sdk:auth');
const { alert, highlight, magenta, note } = snooplogg.styles;
/**
* Authenticates the machine and retreives the auth token.
*/
class Auth {
/**
* The number of seconds before the access token expires and should be refreshed.
*
* @type {Number}
* @access private
*/
tokenRefreshThreshold = 0;
/**
* The store to persist the token.
*
* @type {TokenStore}
* @access private
*/
tokenStore = null;
/**
* Initializes the authentication instance by setting the default settings and creating the
* token store.
*
* @param {Object} [opts] - Various options.
* @param {String} [opts.baseUrl] - The base URL to use for all outgoing requests.
* @param {String} [opts.clientId] - The client id to specify when authenticating.
* @param {String} [opts.clientSecret] - The secret token to use to authenticate.
* @param {String} [opts.env=prod] - The environment name. Must be `staging` or `prod`.
* The environment is a shorthand way of specifying a Axway default base URL.
* @param {Function} [opts.got] - A reference to a `got` HTTP client. If not defined, the
* default `got` instance will be used.
* @param {String} [opts.homeDir] - The path to the home directory containing the `lib`
* directory where `keytar` is located. This option is required when `tokenStoreType` is set to
* `secure`, which is the default.
* @param {Number} [opts.interactiveLoginTimeout] - The number of milliseconds to wait before
* timing out.
* @param {String} [opts.password] - The password used to authenticate. Requires a `username`.
* @param {Boolean} [opts.persistSecrets] - When `true`, adds the authenticator params
* (client secret, private key, username/password) to the authenticated account object so that
* the access token can be refreshed when a refresh token is not available.
* @param {String} [opts.platformUrl] - The URL to redirect the browser to after a
* successful login.
* @param {String} [opts.realm] - The name of the realm to authenticate with.
* @param {Object} [opts.requestOptions] - An options object to pass into Amplify CLI Utils to
* create the `got` HTTP client.
* @param {String} [opts.secretFile] - The path to the PEM formatted private key used to sign
* the JWT.
* @param {String} [opts.secureServiceName="Axway AMPLIFY Auth"] - The name of the consumer
* using this library when using the "secure" token store.
* @param {Boolean} [opts.serviceAccount=false] - When `true`, indicates authentication is being
* requested by a service instead of a user.
* @param {Boolean} [opts.tokenRefreshThreshold=0] - The number of seconds before the access
* token expires and should be refreshed.
* @param {TokenStore} [opts.tokenStore] - A token store instance for persisting the tokens.
* @param {String} [opts.tokenStoreDir] - The directory where the token store is saved. Required
* when the `tokenStoreType` is `secure` or `file`.
* @param {String} [opts.tokenStoreType=secure] - The type of store to persist the access token.
* Possible values include: `auto`, `secure`, `file`, or `memory`. If value is `auto`, it will
* attempt to use `secure`, then `file`, then `memory`. If set to `null`, then it will not
* persist the access token.
* @param {String} [opts.username] - The username used to authenticate. Requires a `password`.
* @access public
*/
constructor(opts = {}) {
if (!opts || typeof opts !== 'object') {
throw errors.INVALID_ARGUMENT('Expected options to be an object');
}
if (opts.tokenRefreshThreshold !== undefined) {
const threshold = parseInt(opts.tokenRefreshThreshold, 10);
if (isNaN(threshold)) {
throw errors.INVALID_PARAMETER('Expected token refresh threshold to be a number of seconds');
}
if (threshold < 0) {
throw errors.INVALID_RANGE('Token refresh threshold must be greater than or equal to zero');
}
this.tokenRefreshThreshold = threshold;
}
Object.defineProperties(this, {
baseUrl: { value: opts.baseUrl },
clientId: { value: opts.clientId },
clientSecret: { value: opts.clientSecret },
got: { value: opts.got || request.init(opts.requestOptions) },
interactiveLoginTimeout: { value: opts.interactiveLoginTimeout },
messages: { value: opts.messages },
password: { value: opts.password },
realm: { value: opts.realm },
persistSecrets: { writable: true, value: opts.persistSecrets },
platformUrl: { value: opts.platformUrl },
secretFile: { value: opts.secretFile },
serviceAccount: { value: opts.serviceAccount },
username: { value: opts.username }
});
this.env = resolve(opts.env).name;
if (opts.tokenStore) {
if (!(opts.tokenStore instanceof TokenStore)) {
throw errors.INVALID_PARAMETER('Expected the token store to be a "TokenStore" instance');
}
this.tokenStore = opts.tokenStore;
} else {
const tokenStoreType = opts.tokenStoreType === undefined ? 'secure' : opts.tokenStoreType;
switch (tokenStoreType) {
case 'auto':
case 'secure':
try {
this.tokenStore = new SecureStore(opts);
// we know we're secure, so force persist secrets
if (this.persistSecrets === undefined) {
this.persistSecrets = true;
}
break;
} catch (e) {
/* istanbul ignore if */
if (tokenStoreType === 'auto') ; else if (e.code === 'ERR_KEYTAR_NOT_FOUND') {
throw errors.SECURE_STORE_UNAVAILABLE('Secure token store is not available.\nPlease reinstall or rebuild this application.');
} else {
throw e;
}
}
case 'file':
try {
this.tokenStore = new FileStore(opts);
break;
} catch (e) {
/* istanbul ignore if */
if (tokenStoreType === 'auto' && e.code === 'ERR_MISSING_REQUIRED_PARAMETER') ; else {
throw e;
}
}
case 'memory':
this.tokenStore = new MemoryStore(opts);
break;
}
}
if (this.persistSecrets && !(this.tokenStore instanceof SecureStore)) {
warn('Persist secrets has been enabled for non-secure token store');
warn('Run "axway config rm auth.persistSecrets" to disable');
}
}
/**
* Ensures the options contains the configurable settings. Validation is handled by the code
* requiring the values.
*
* @param {Object} [opts] - Various options.
* @returns {Object}
* @access private
*/
applyDefaults(opts = {}) {
if (!opts || typeof opts !== 'object') {
throw errors.INVALID_ARGUMENT('Expected options to be an object');
}
const name = opts.env || this.env;
const env = resolve(name);
if (!env) {
throw errors.INVALID_VALUE(`Invalid environment: ${name}`);
}
// copy the options so we don't modify the original object since we don't own it
return {
...opts,
baseUrl: opts.baseUrl || this.baseUrl || env.baseUrl,
clientId: opts.clientId || this.clientId,
clientSecret: opts.clientSecret || this.clientSecret,
env: env.name,
got: opts.got || this.got,
messages: opts.messages || this.messages,
password: opts.password || this.password,
persistSecrets: opts.persistSecrets !== undefined ? opts.persistSecrets : this.persistSecrets,
platformUrl: opts.platformUrl || this.platformUrl,
realm: opts.realm || this.realm,
secretFile: opts.secretFile || this.secretFile,
serviceAccount: opts.serviceAccount || this.serviceAccount,
timeout: opts.timeout || opts.interactiveLoginTimeout || this.interactiveLoginTimeout,
tokenStore: this.tokenStore,
username: opts.username || this.username
};
}
/**
* Creates an authenticator based on the supplied options.
*
* @param {Object} [opts] - Various options.
* @param {Authenticator} [opts.authenticator] - An authenticator instance to use. If not
* specified, one will be auto-selected based on the options.
* @param {String} [opts.clientSecret] - The secret token to use to authenticate.
* @param {String} [opts.password] - The password used to authenticate. Requires a `username`.
* @param {String} [opts.secretFile] - The path to the jwt secret file.
* @param {Boolean} [opts.serviceAccount=false] - When `true`, indicates authentication is being
* requested by a service instead of a user.
* @param {String} [opts.username] - The username used to authenticate. Requires a `password`.
* @returns {Authenticator}
* @access public
*/
createAuthenticator(opts = {}) {
if (opts.authenticator) {
if (!(opts.authenticator instanceof Authenticator)) {
throw errors.INVALID_ARUGMENT('Expected authenticator to be an Authenticator instance.');
}
log(`Using existing ${highlight(opts.authenticator.constructor.name)} authenticator`);
return opts.authenticator;
}
if (opts.persistSecrets === undefined) {
opts.persistSecrets = this.persistSecrets;
}
if (typeof opts.username === 'string' && opts.username && typeof opts.password === 'string') {
log(`Creating ${highlight('OwnerPassword')} authenticator`);
return new OwnerPassword(opts);
}
if (typeof opts.clientSecret === 'string' && opts.clientSecret) {
log(`Creating ${highlight('ClientSecret')} authenticator`);
return new ClientSecret(opts);
}
if (typeof opts.secretFile === 'string' && opts.secretFile) {
log(`Creating ${highlight('SignedJWT')} authenticator`);
return new SignedJWT(opts);
}
log(`Creating ${highlight('PKCE')} authenticator`);
return new PKCE(opts);
}
/**
* Finds an authenticated account using either the account name or the authentication
* parameters used to authenticate.
*
* This method is called by the AmplifySDK's `auth.login()` which uses the auth params
* (baseUrl, clientId, realm, plus authenticator specific data) to generate a unique hash which
* is then used to find an authenticated account. This is helpful to detect if you've already
* authenticated.
*
* It's important to note that the login command's `--client-id` takes on a different meaning
* compared to other commands. The login command will use the client id to generate the unique
* authenticator hash. This means that any command other, specifically ones like the
* `service-account` command, that have also have a `--client-id`, must operate against a known
* account name and NOT use their `--client-id` for the auth params that generates the unique
* authenticator hash.
*
* @param {Object|String} opts - Required options or a string containing the hash or account
* name.
* @param {String} opts.accountName - The account name to retrieve.
* @param {Authenticator} [opts.authenticator] - An authenticator instance to use. If not
* specified, one will be auto-selected based on the options.
* @param {String} [opts.baseUrl] - The base URL to filter by.
* @returns {Promise<?Object>}
* @access public
*/
async find(opts = {}) {
if (!this.tokenStore) {
log('Cannot get account, no token store');
return null;
}
let authenticator;
if (typeof opts === 'string') {
opts = this.applyDefaults({ accountName: opts, hash: opts });
} else {
opts = this.applyDefaults(opts);
authenticator = this.createAuthenticator(opts);
log(`Authenticator hash: ${highlight(authenticator.hash)}`);
opts.hash = authenticator.hash;
}
const account = await this.tokenStore.get(opts);
if (!account) {
return;
}
// copy over the correct auth params
for (const prop of [ 'baseUrl', 'clientId', 'realm', 'env', 'clientSecret', 'username', 'password', 'secret' ]) {
if (account.auth[prop] && opts[prop] !== account.auth[prop]) {
log(`Overriding "${prop}" auth param with account's: ${opts[prop]} -> ${account.auth[prop]}`);
opts[prop] = account.auth[prop];
}
}
authenticator = this.createAuthenticator(opts);
const { access, refresh } = account.auth.expires;
let doRefresh = account.auth.expired;
if (doRefresh) {
log(`Access token for account ${highlight(account.name || account.hash)} has expired`);
if ((refresh || access) < Date.now()) {
log(`Unable to refresh access token for account ${highlight(account.name || account.hash)} because refresh token is also expired`);
return;
}
} else {
const expiresIn = (refresh || access) - Date.now();
if (this.tokenRefreshThreshold && expiresIn < this.tokenRefreshThreshold * 1000) {
log(`Access token is valid, but will expire in ${expiresIn}ms (threshold ${this.tokenRefreshThreshold * 1000}ms), refreshing now`);
doRefresh = true;
} else {
log(`Access token is valid and does not need to be refreshed, but will expire in ${expiresIn}ms (threshold ${this.tokenRefreshThreshold * 1000}ms)`);
}
}
if (doRefresh) {
try {
log(`Refreshing access token for account ${highlight(account.name || account.hash)}`);
return await authenticator.getToken(null, null, true);
} catch (err) {
if (err.code !== 'EINVALIDGRANT') {
throw err;
}
if (account.auth.expired) {
log(`Removing invalid account ${highlight(account.name || account.hash)} due to invalid refresh token`);
warn(err.toString());
await this.tokenStore.delete(account.name, opts.baseUrl);
return null;
} else {
log(`Couldn't refresh account ${highlight(account.name || account.hash)}: ${err.toString()}`);
}
}
}
try {
return await authenticator.getInfo(account);
} catch (err) {
if (err.statusCode === 401) {
warn(`Removing invalid account ${highlight(account.name || account.hash)} due to stale token`);
await this.tokenStore.delete(account.name, opts.baseUrl);
return null;
}
if (!account.auth.expired) {
log(`Couldn't refresh account ${highlight(account.name || account.hash)} info, skipping: ${err.toString()}`);
return account;
}
throw err;
}
}
/**
* Returns a list of all valid access tokens.
*
* @returns {Promise<Array>}
* @access public
*/
async list() {
if (this.tokenStore) {
return (await this.tokenStore.list())
.filter(account => (account.auth.env || 'prod') === this.env);
}
return [];
}
/**
* Authenticates using the configured authenticator.
*
* @param {Object} [opts] - Various options.
* @param {String|Array.<String>} [opt.app] - The web browser app to open the `target` with, or
* an array with the app and app arguments.
* @param {Authenticator} [opts.authenticator] - An authenticator instance to use. If not
* specified, one will be auto-selected based on the options.
* @param {String} [opts.baseUrl] - The base URL to use for all outgoing requests.
* @param {String} [opts.clientId] - The client id to specify when authenticating.
* @param {String} [opts.code] - The authentication code from a successful interactive login.
* @param {String} [opts.env=prod] - The environment name. Must be `staging` or `prod`.
* The environment is a shorthand way of specifying a Axway default base URL.
* @param {Boolean} [opts.manual=false] - When `true`, it will return the auth URL instead of
* launching the auth URL in the default browser.
* @param {Function} [opts.onOpenBrowser] - A callback when the web browser is about to be
* launched.
* @param {String} [opts.realm] - The name of the realm to authenticate with.
* @param {Number} [opts.timeout] - The number of milliseconds to wait before timing out.
* @returns {Promise<Object>} Resolves an object containing the access token, account name, and
* user info.
* @access public
*/
async login(opts = {}) {
opts = this.applyDefaults(opts);
const authenticator = this.createAuthenticator(opts);
return await authenticator.login(opts);
}
/**
* Revokes all or specific authenticated accounts.
*
* @param {Object} opts - Required options.
* @param {Array.<String>} opts.accounts - A list of accounts names or hashes.
* @param {Boolean} [opts.all] - When `true`, revokes all accounts.
* @param {String} [opts.baseUrl] - The base URL used to filter accounts.
* @returns {Promise<Array>} Resolves a list of revoked credentials.
* @access public
*/
async logout({ accounts, all, baseUrl } = {}) {
if (!this.tokenStore) {
log('No token store, returning empty array');
return [];
}
if (!all) {
if (!accounts) {
throw errors.INVALID_ARGUMENT('Expected accounts to be a list of accounts');
}
if (typeof accounts === 'string') {
accounts = [ accounts ];
}
if (!Array.isArray(accounts)) {
throw errors.INVALID_ARGUMENT('Expected accounts to be a list of accounts');
}
if (!accounts.length) {
return [];
}
}
let revoked;
if (all) {
revoked = await this.tokenStore.clear(baseUrl);
} else {
revoked = await this.tokenStore.delete(accounts, baseUrl);
}
if (Array.isArray(revoked)) {
for (const entry of revoked) {
// don't logout of platform accounts here, it's done in the Amplify SDK by opening the browser
if (!entry.isPlatform) {
const { platformUrl } = resolve(entry.auth.env);
const url = `${platformUrl}/auth/signout?id_token_hint=${entry.auth.tokens.id_token}`;
try {
const { statusCode } = await this.got(url, { responseType: 'json', retry: 0 });
log(`Successfully logged out ${highlight(entry.name)} ${magenta(statusCode)} ${note(`(${entry.auth.baseUrl}, ${entry.auth.realm})`)}`);
} catch (err) {
log(`Failed to log out ${highlight(entry.name)} ${alert(err.status)} ${note(`(${entry.auth.baseUrl}, ${entry.auth.realm})`)}`);
}
}
}
}
return revoked;
}
/**
* Discovers available endpoints based on the authentication server's OpenID configuration.
*
* @param {Object} [opts] - Various options.
* @param {String} [opts.baseUrl] - The base URL to use for all outgoing requests.
* @param {String} [opts.env=prod] - The environment name. Must be `staging` or `prod`.
* The environment is a shorthand way of specifying a Axway default base URL.
* @param {String} [opts.realm] - The name of the realm to authenticate with.
* @param {String} [opts.url] - An optional URL to discover the available endpoints.
* @returns {Promise<Object>}
* @access public
*/
async serverInfo(opts = {}) {
opts = this.applyDefaults(opts);
let { url } = opts;
if (!url) {
url = getEndpoints(opts).wellKnown;
}
if (!url || typeof url !== 'string') {
throw errors.INVALID_ARGUMENT('Expected URL to be a non-empty string');
}
try {
log(`Fetching server info: ${highlight(url)}...`);
return (await this.got(url, { responseType: 'json', retry: 0 })).body;
} catch (err) {
if (err.name !== 'ParseError') {
err.message = `Failed to get server info (status ${err.response.statusCode})`;
}
throw err;
}
}
/**
* Update the stored account.
*
* @param {Object} account - An object containing the account info.
* @returns {Promise}
* @access public
*/
async updateAccount(account) {
if (this.tokenStore) {
await this.tokenStore.set(account);
}
}
}
export { Auth as default };
//# sourceMappingURL=auth.js.map