UNPKG

@axway/amplify-sdk

Version:

Axway Amplify SDK for Node.js

608 lines (538 loc) 18.3 kB
import errors from '../errors.js'; import ejs from 'ejs'; import fs from 'fs-extra'; import getEndpoints from '../endpoints.js'; import jws from 'jws'; import open from 'open'; import path, { dirname } from 'path'; import snooplogg from 'snooplogg'; import TokenStore from '../stores/token-store.js'; import { r as resolve } from '../environments-C3ppEMBw.js'; import * as request from '@axway/amplify-request'; import Server from '../server.js'; import { md5, createURL, prepareForm } from '../util.js'; import { fileURLToPath } from 'url'; import 'pluralize'; import 'crypto'; import 'get-port'; import 'http'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const { log, warn } = snooplogg('amplify-auth:authenticator'); const { green, highlight, red, note } = snooplogg.styles; /** * Orchestrates authentication and token management. */ class Authenticator { /** * List of valid grant types to include with server requests. * * @type {Object} * @access public */ static GrantTypes = { AuthorizationCode: 'authorization_code', ClientCredentials: 'client_credentials', Password: 'password', RefreshToken: 'refresh_token', JWTAssertion: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' }; /** * The access type to send with requests. * * @type {String} * @access private */ accessType = 'offline'; /** * Defines if this authentication method is interactive. If `true`, then it will not attempt to * automatically reinitialize expired tokens. * * @type {Boolean} * @access private */ interactive = false; /** * 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. * @type {Boolean} * @access private */ persistSecrets = false; /** * The authorize URL. * * @type {String} * @access private */ responseType = 'code'; /** * The scope to send with requests. * * @type {String} * @access private */ scope = 'openid'; /** * The store to persist the token. * * @type {TokenStore} * @access private */ tokenStore = null; /** * Initializes the authenticator instance. * * @param {Object} opts - Various options. * @param {String} [opts.accessType=offline] - The access type to send with requests. * @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 {Object} [opts.endpoints] - A map of endpoint names to endpoint URLs. Possible * endpoints are: `auth`, `certs`, `logout`, `token`, `userinfo`, and `wellKnown`. * @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.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 {Function} [opts.got] - A reference to a `got` HTTP client. If not defined, the * default `got` instance will be used. * @param {String} opts.realm - The name of the realm to authenticate with. * @param {String} [opts.responseType=code] - The response type to send with requests. * @param {String} [opts.scope=openid] - The name of the scope to send with requests. * @param {TokenStore} [opts.tokenStore] - A token store instance for persisting the tokens. * @access public */ constructor(opts) { if (!opts || typeof opts !== 'object') { throw errors.INVALID_ARGUMENT('Expected options to be an object'); } // check the environment this.env = resolve(opts.env); // process the base URL if (opts.baseUrl) { this.baseUrl = opts.baseUrl; } if (!this.baseUrl || typeof this.baseUrl !== 'string') { throw errors.MISSING_REQUIRED_PARAMETER('Invalid base URL: env or baseUrl required'); } this.platformUrl = opts.platformUrl || this.env.platformUrl; this.persistSecrets = opts.persistSecrets; // validate the required string properties for (const prop of [ 'clientId', 'realm' ]) { if (opts[prop] === undefined || !opts[prop] || typeof opts[prop] !== 'string') { throw errors.MISSING_REQUIRED_PARAMETER(`Expected required parameter "${prop}" to be a non-empty string`); } this[prop] = opts[prop]; } // validate optional string options for (const prop of [ 'accessType', 'responseType', 'scope' ]) { if (opts[prop] !== undefined) { if (typeof opts[prop] !== 'string') { throw errors.INVALID_PARAMETER(`Expected parameter "${prop}" to be a string`); } this[prop] = opts[prop]; } } // define the endpoints this.endpoints = getEndpoints(this); // set any endpoint overrides if (opts.endpoints) { if (typeof opts.endpoints !== 'object') { throw errors.INVALID_PARAMETER('Expected endpoints to be an object of names to URLs'); } for (const [ name, url ] of Object.entries(opts.endpoints)) { if (!url || typeof url !== 'string') { throw errors.INVALID_PARAMETER(`Expected "${name}" endpoint URL to be a non-empty string`); } if (!this.endpoints[name]) { throw errors.INVALID_VALUE(`Invalid endpoint "${name}"`); } this.endpoints[name] = url; } } 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; } this.got = opts.got || request.got; } /* istanbul ignore next */ /** * This property is meant to be overridden by authenticator implementations. * * @type {?Object} * @access private */ get authorizationUrlParams() { return null; } /** * Populates the latest user and session info into an account object. * * @param {Object} account - An object containing the account info. * @returns {Object} The original account object. * @access public */ async getInfo(account) { try { const accessToken = account.auth.tokens.access_token; log(`Fetching user info: ${highlight(this.endpoints.userinfo)} ${note(accessToken)}`); const { body } = await this.got(this.endpoints.userinfo, { headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` }, responseType: 'json', retry: 0 }); const { email, family_name, given_name, guid, org_guid, org_name } = body; if (!account.user || typeof account.user !== 'object') { account.user = {}; } account.user.email = email; account.user.firstName = given_name; account.user.guid = guid || account.user.guid; account.user.lastName = family_name; if (!account.org || typeof account.org !== 'object') { account.org = {}; } account.org.name = org_name; account.org.guid = org_guid; } catch (err) { const status = err.response?.statusCode; warn(`Fetch user info failed: ${err.message}${status ? ` (${status})` : ''}`); } return account; } /** * Authenticates with the server and retrieves the access and refresh tokens. * * The `code` is undefined when doing a non-interactive login. * * @param {String} [code] - When present, adds the code to the payload along with a redirect * URL. * @param {String} [redirectUri] - The redirect URI that was passed in when first retrieving * the code. * @param {Boolean} [force] - When `true`, bypasses an existing valid token and gets a new * token using the existing token. * @returns {Promise<Object>} Resolves the account object. * @access private */ async getToken(code, redirectUri, force) { let now = Date.now(); let expires; let tokens; let response; // if you have a code, then you probably don't want to have gone through all the hassle of // getting the code to only return the existing access token from the store if (!code && this.tokenStore) { log(`Searching for existing tokens: ${highlight(this.hash)}`); for (const entry of await this.tokenStore.list()) { if (entry.hash === this.hash) { log('Found account in token store:'); log(entry); ({ expires, tokens } = entry.auth); if (!force && tokens.access_token && expires.access > now) { log('Token is still valid'); return entry; } break; } } } const url = this.endpoints.token; const fetchTokens = async params => { try { log(`Fetching token: ${highlight(url)}`); log('Post form:', { ...params, password: '********' }); const response = await this.got.post(url, { form: prepareForm(params), responseType: 'json' }); log(`${(response.statusCode >= 400 ? red : green)(String(response.statusCode))} ${highlight(url)}`); return response; } catch (err) { if (err.code === 'ECONNREFUSED') { // don't change the code, just re-throw throw err; } log(err); const desc = err.response?.body?.error_description; if (err.response?.body?.error === 'invalid_grant') { const e = errors.AUTH_FAILED(`Invalid Grant${desc ? `: ${desc}` : ''}`); e.code = 'EINVALIDGRANT'; e.statusCode = err.response.statusCode; e.statusMessage = err.response.statusMessage; throw e; } const e = errors.AUTH_FAILED(`Authentication failed: ${desc ? `${desc}: ` : ''}${err.message}`); e.body = err.response?.body; e.statusCode = err.response?.statusCode; e.statusMessage = err.response?.statusMessage; throw e; } }; if (tokens?.refresh_token && expires.refresh && expires.refresh > now) { log('Refreshing token using refresh token'); response = await fetchTokens(Object.assign({ clientId: this.clientId, grantType: Authenticator.GrantTypes.RefreshToken, refreshToken: tokens.refresh_token }, this.refreshTokenParams)); } else { // get new token using the code const params = Object.assign({ clientId: this.clientId, scope: this.scope }, this.tokenParams); if (this.interactive) { if (!code || typeof code !== 'string') { throw errors.MISSING_AUTH_CODE('Expected code for interactive authentication to be a non-empty string'); } params.code = code; params.redirectUri = redirectUri; } response = await fetchTokens(params); } tokens = response.body; log(`Authentication successful ${note(`(${response.headers['content-type']})`)}`); log(tokens); let email; let guid; let idp; let org; let name = this.hash; try { const info = jws.decode(tokens.id_token || tokens.access_token); if (typeof info.payload === 'string') { info.payload = JSON.parse(info.payload); } log(info); email = (info.payload.email || '').trim(); guid = info.payload.guid; if (email) { name = `${this.clientId}:${email}`; } idp = info.payload.identity_provider; const { orgId } = info.payload; if (orgId) { org = { name: orgId, id: orgId }; } } catch (e) { throw errors.AUTH_FAILED('Authentication failed: Invalid server response'); } // refresh `now` and set the expiry timestamp now = Date.now(); const refresh = tokens.refresh_token && tokens.refresh_expires_in ? (tokens.refresh_expires_in * 1000) + now : null; const account = await this.getInfo({ auth: { authenticator: this.constructor.name, baseUrl: this.baseUrl, clientId: this.clientId, env: this.env.name, expires: { access: (tokens.expires_in * 1000) + now, refresh }, idp, realm: this.realm, tokens }, hash: this.hash, name, org, orgs: org ? [ org ] : [], user: { axwayId: undefined, email, firstName: undefined, guid, lastName: undefined, organization: undefined } }); if (this.persistSecrets) { // add the secrets to the acount object so that they can be used to refresh the access // token when there's no refresh token. log('Persisting secrets in token store'); Object.assign(account.auth, this.authenticatorParams); } else { log('Not persisting secrets in token store'); } // persist the tokens if (this.tokenStore) { await this.tokenStore.set(account); } if (!Object.getOwnPropertyDescriptor(account.auth, 'expired')) { Object.defineProperty(account.auth, 'expired', { configurable: true, get() { return this.expires.access < Date.now(); } }); } return account; } /** * Generate the hash that attempts to uniquely identify this authentication methods and its * parameters. * * @type {String} * @access public */ get hash() { return this.clientId.replace(/\s/g, '_').replace(/_+/g, '_') + ':' + md5(Object.assign({ baseUrl: this.baseUrl, env: this.env.name === 'prod' ? undefined : this.env.name, realm: this.realm }, this.hashParams)); } /* istanbul ignore next */ /** * This property is meant to be overridden by authenticator implementations. * * @type {?Object} * @access private */ get hashParams() { return null; } get authenticatorParams() { return null; } /** * Orchestrates an interactive login flow or retrieves the access token non-interactively. * * @param {Object} [opts] - Various options. * @param {String|Array.<String>} [opt.app] - Specify the app to open the `target` with, or an * array with the app and app arguments. * @param {String} [opts.code] - The authentication code from a successful interactive login. * @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 {Number} [opts.timeout=120000] - The number of milliseconds to wait before timing * out. * @returns {Promise<Object>} In `manual` mode, then resolves an object containing the * authentication `url`, a `promise` that is resolved once the browser redirects to the local * web server after authenticating, and a `cancel` method to abort the authentication and stop * the local web server. When not using `manual` mode, the `account` info is resolved after * successfully authenticating. * @access public */ async login(opts = {}) { if (!this.interactive || opts.code !== undefined) { if (this.interactive) { log('Retrieving tokens using auth code'); } else if (opts.manual) { throw new Error('Manual mode is only supported with PKCE interactive authentication'); } else { log('Retrieving tokens non-interactively'); } return await this.getToken(opts.code); } // we're interactive, so we either are manual or starting a web server const server = new Server({ timeout: opts.timeout }); const orgSelectedCallback = await server.createCallback(async (req, res) => { await res.writeHead(302, { 'Content-Type': 'text/html', Location: this.platformUrl }); const template = path.resolve(__dirname, '../../templates/auth.html.ejs'); res.end(ejs.render(await fs.readFile(template, 'utf-8'), { title: 'Authorization Successful!', message: 'Please return to the console.' })); return true; }); const codeCallback = await server.createCallback(async (req, res, { searchParams }) => { const code = searchParams.get('code'); if (!code) { res.end(); throw new Error('Invalid auth code'); } log(`Getting token using code: ${highlight(code)}`); const account = await this.getToken(code, codeCallback.url); let redirect = orgSelectedCallback.url; // if we just authenticated using a IdP user (e.g. not 360) if (account.auth.idp !== '360') { redirect = createURL(`${this.platformUrl}/#/auth/org.select`, { redirect: orgSelectedCallback.url }); } log(`Waiting for platform org select to finish and redirect to ${highlight(redirect)}`); res.writeHead(302, { Location: redirect }); res.end(); }); const authorizationUrl = createURL(this.endpoints.auth, Object.assign({ accessType: this.accessType, clientId: this.clientId, scope: this.scope, responseType: this.responseType, redirectUri: codeCallback.url }, this.authorizationUrlParams)); log(`Starting ${opts.manual ? 'manual ' : ''}login request clientId=${highlight(this.clientId)} realm=${highlight(this.realm)}`); const loginAccount = codeCallback.start().then(async () => { return await orgSelectedCallback.start() .then(async (res) => { if (res) { await this.timeout(); return await this.getToken(undefined, undefined, true); } }); }).finally(() => server.stop()); // if manual, return now with the auth url if (opts.manual) { return { cancel: () => Promise.all([ codeCallback.cancel(), orgSelectedCallback.cancel() ]), loginAccount, url: authorizationUrl }; } // launch the default web browser log(`Launching default web browser: ${highlight(authorizationUrl)}`); if (typeof opts.onOpenBrowser === 'function') { await opts.onOpenBrowser({ url: authorizationUrl }); } try { await open(authorizationUrl, opts); } catch (err) { const m = err.message.match(/Exited with code (\d+)/i); throw m ? new Error(`Failed to open web browser (code ${m[1]})`) : err; } // wait for authentication to succeed or fail return loginAccount; } async timeout() { return new Promise(resolve => setTimeout(resolve, 3000)); } /* istanbul ignore next */ /** * This property is meant to be overridden by authenticator implementations. * * @type {?Object} * @access private */ get refreshTokenParams() { return null; } /* istanbul ignore next */ /** * This property is meant to be overridden by authenticator implementations. * * @type {?Object} * @access private */ get tokenParams() { return null; } } export { Authenticator as default }; //# sourceMappingURL=authenticator.js.map