UNPKG

bungie-auth

Version:
387 lines (335 loc) 10.4 kB
import diagnostics from 'diagnostics'; import TickTock from 'tick-tock'; import failure from 'failure'; import request from 'request'; import URL from 'url-parse'; import uuid from 'uuid/v4'; // // Setup our debug utility so we can figure out what is going on in with the // module internals. // const debug = diagnostics('bungie-auth'); /** * Access oAuth from Bungie.net * * @param {Object} opts Configuration. * @constructor * @public */ export default class Bungo { constructor(opts) { opts = Object.assign({}, Bungo.defaults, opts || {}); // // References to the API responses from Bungie.net // this.refreshToken = opts.refreshToken || null; this.accessToken = opts.accessToken || null; this.redirectURL = opts.redirectURL; this.timers = new TickTock(this); this.config = opts; this.state = null; if (this.accessToken && this.refreshToken) { debug('had a pre-existing access and refresh token. Starting timeout'); this.setTimeout(); } } /** * Open a new browser window for the oAuth authorization flow. * * @param {Function} fn Completion callback. * @private */ open(fn) { return fn(failure('Left as implementation excercise for developers.')); } /** * Generate the URL that starts the oAuth flow. * * @returns {URL} Our URL instance, that can toString in to an URL. * @public */ url() { const target = new URL(this.config.url, true); this.state = uuid(); target.set('query', { state: this.state }); debug('created a oauth URL %s', target.href); return target.href; } /** * Check if the received URL is valid and secure enough to continue with our * authentication steps. * * @param {String} url URL we need to check for possible miss matches. * @returns {Boolean} Passed or failed our test. * @public */ secure(url) { const target = new URL(url, true); return this.state === target.query.state; } /** * Request accessToken. * * @param {Function} fn Completion callback * @public */ request(fn) { this.open(this.redirectURL, (err, url) => { if (err) { debug('failed to open the authorization window'); return fn(err); } debug('processing redirection URL', url); const target = new URL(url, true); if (!target.query.code) { debug('the user as declined the oauth access, no code was received from bungie'); return fn(new Error('User as declined the oAuth request')); } this.send('GetAccessTokensFromCode', { code: target.query.code }, this.capture(fn)); }); } /** * Get the access token from the API, if we don't have a refresh token we want * to request access to the user's details. * * @param {Function} fn Completion callback. * @public */ token(fn) { // // 1: // // Look if we have a stored accessToken and check if it's still somewhat // valid. // if (!this.expired(this.accessToken)) { debug('accessToken is not yet expired, using cached access token'); return fn(undefined, this.payload()); } // // 2: // // If we still have a refresh token, use it to generate a new access token. // if (!this.expired(this.refreshToken)) { debug('refreshToken is not yet expired, using cached refresh token'); return this.refresh(fn); } // // 3: // // Abandon all hope, ask for another sign in as we have no token, no refresh // token, no nothing. The world is a sad place, and addition user actions // have to be taken. // debug('no working access and refresh tokens found, starting oauth flow'); this.request(fn); } /** * Check if our given token is alive and kicking or if it's expired. * * @param {Object} token Token object received from Bungie.net * @returns {Boolean} * @public */ expired(token) { if (!token || typeof token !== 'object' || !token.value || !token.epoch || !token.expires) { debug('no valid token received assume its expired'); return true; } // // We transform the difference in epoch to seconds and remove a small amount // of buffering so people actually have some time to do an API request with // the returned token. // const diff = this.alive(token) + (this.config.buffer / 2); const canbeused = token.expires < diff; debug('token expires %j/%j seconds, expired:', diff, token.expires, canbeused); return canbeused; } /** * Calculate the time in seconds that the token has been alive. * * @returns {Number} time in seconds the token has been alive * @public */ alive(token) { const now = Date.now(); return Math.ceil((now - token.epoch) / 1000); } /** * Send a token request to Bungie.net. * * @param {String} pathname Pathname we need to hit. * @param {Object} body Request body. * @param {Function} fn Completion callback. * @public */ send(pathname, body, fn) { const url = 'https://www.bungie.net/Platform/App/'+ pathname +'/'; debug('sending API request to %s', url); request({ url: url, json: true, method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-API-Key': this.config.key, 'Accept': 'application/json', } }, (err, res, body) => { if (err) { debug('received an error while making an API request', err); return fn(err); } // // Handle invalid responses because the site is down. // if (res.statusCode !== 200 || typeof body !== 'object') { debug('invalid response received from bungie server', res.statusCode, body); return fn(failure('Bungie API returned an error, try again later.'), { statusCode: res.statusCode }); } fn(undefined, body); }); } /** * Refresh the token. * * @param {String} token Optional refresh token. Will used last known. * @param {Function} fn Completion callback. * @public */ refresh(token, fn) { if ('function' === typeof token) { fn = token; token = null; if (this.refreshToken) { token = this.refreshToken; } } // // @TODO when we don't have a token, do we want to trigger another // authentication request? // if (!token) { return fn(failure('Missing refresh token')); } return this.send('GetAccessTokensFromRefreshToken', { refreshToken: token.value }, this.capture(fn)); } /** * Try to keep the internally cached accessToken as fresh as possible so * our `.token` method is as fast as it can be. We want to make sure that * we give our API some extra time to do the lookup so we'll subtract 60 * seconds from the expiree. * * @private */ setTimeout() { if (!this.config.fresh) return; this.timers.clear('refresh'); let remaining = this.accessToken.expires - this.alive(this.accessToken); // // Remove the time from our buffer so we have spare time to refresh the // token without disrupting the application. But we need to make sure // that we still keep a positive integer when setting our timeout so // default to 0. // remaining = remaining - this.config.buffer; if (remaining < 0) remaining = 0; debug('updating setTimeout for refreshToken in %s seconds', remaining); this.timers.setTimeout('refresh', () => { debug('our refreshToken is about to expire, initating auto-refresh'); this.refresh(this.config.fresh); }, remaining + ' seconds'); } /** * Capture the response from the Bungie servers so we can apply some * additional processing. * * @param {Function} fn Completion callback. * @returns {Function} Interception callback. * @private */ capture(fn) { return (err, body = {}) => { if (err) { debug('Bungie API request failed hard: %s', err.message); return fn(err); } // // Handle various of failures. // if (!('Response' in body) || 'Success' !== body.ErrorStatus) { const message = body.Message || 'Invalid data received from Bungie.net'; // // The internal authorizationCode is invalid, we should ask the user to // login again. // if (body.ErrorStatus === 'AuthorizationCodeInvalid') { this.refreshToken = null; this.accessToken = null; debug('our authorization code is invalid, whipe and re-authenticate'); return this.request(fn); } debug('Invalid response received from Bungie servers: %s', message); return fn(failure(message, { status: body.ErrorStatus })); } const refreshToken = this.refreshToken; const data = body.Response; const now = Date.now(); // // The API responses only have a `expires` and `readyin` values which // started when the API request was made. So if you store these values you // really have no clue if you can still use the accessToken or // refreshToken. // data.refreshToken.epoch = now data.accessToken.epoch = now; this.refreshToken = data.refreshToken; this.accessToken = data.accessToken; const payload = this.payload(); // // Check if we previously already had data or if this is actually the // first time we received data because in that case we also want to // trigger the fresh function so it can be used as storage callback for // applications // if (this.config.fresh && !refreshToken) { debug('first time calling refresh as we didnt have a token before'); this.config.fresh(err, payload); } this.setTimeout(); fn(err, payload); }; } /** * The payload that is returned to the user. * * @returns {Object} The API payload. * @public */ payload() { return { refreshToken: this.refreshToken, accessToken: this.accessToken }; } } /** * Default options. * * @type {Object} * @private */ Bungo.defaults = { url: 'https://www.bungie.net/en/Application/Authorize/', buffer: 60, fresh: false };