UNPKG

indieauth-authentication

Version:

A helper class for creating apps that authenticate via IndieAuth

367 lines (325 loc) 13.9 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); var _dependencies = require('./dependencies'); var dependencies = _interopRequireWildcard(_dependencies); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } var qsParse = dependencies.qsParse; var relScraper = dependencies.relScraper; var qsStringify = dependencies.qsStringify; var objectToFormData = dependencies.objectToFormData; var appendQueryString = dependencies.appendQueryString; if (dependencies.FormData && !global.FormData) { global.FormData = dependencies.FormData; } if (dependencies.JSDOM && !global.DOMParser) { global.DOMParser = new dependencies.JSDOM().window.DOMParser; } if (dependencies.URL && !global.URL) { global.URL = dependencies.URL; } var defaultSettings = { me: '', token: '', // want more endpoints, or name them differently? // you can override relEndpoints when creating an IndieAuthentication! relEndpoints: { 'authorization_endpoint': 'auth', 'token_endpoint': 'token', 'micropub': 'micropub' // pass in `scope` when creating a new instance if you want to // get an access token suitable for micropub/-sub } }; // FIXME: internal mappings, always capture authorization and // token endpoints so we can use them to build URLs etc.! var iauthnError = function iauthnError(message) { var status = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; var error = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : null; return { message: message, status: status, error: error }; }; var IndieAuthentication = function () { function IndieAuthentication() { var userSettings = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; _classCallCheck(this, IndieAuthentication); this.options = Object.assign({}, defaultSettings, userSettings); // Bind all the things this.checkRequiredOptions = this.checkRequiredOptions.bind(this); this.getAuthUrl = this.getAuthUrl.bind(this); this.getEndpointsFromUrl = this.getEndpointsFromUrl.bind(this); } /** * Checks to see if the given options are set * @param {array} requirements An array of option keys to check * @return {object} An object with boolean pass property and array missing property listing missing options */ _createClass(IndieAuthentication, [{ key: 'checkRequiredOptions', value: function checkRequiredOptions(requirements) { var missing = []; var pass = true; for (var i = 0; i < requirements.length; i++) { var optionName = requirements[i]; var option = this.options[optionName]; if (!option) { pass = false; missing.push(optionName); } } return { pass: pass, missing: missing }; } /** * Canonicalize the given url according to the rules at * https://indieauth.spec.indieweb.org/#url-canonicalization * @param {string} url The url to canonicalize * @return {string} The canonicalized url. */ }, { key: 'getCanonicalUrl', value: function getCanonicalUrl(url) { return new URL(url).href; } /** * Fetch a URL, keeping track of 301 redirects to update * https://indieauth.spec.indieweb.org/#redirect-examples * @param {string} url The url to scrape * @return {Promise} Passes the fetch response object and the "final" url. */ }, { key: 'getUrlWithRedirects', value: function getUrlWithRedirects(url) { var _this = this; return new Promise(function (fulfill, reject) { // fetch the url fetch(url, { redirect: 'manual' }).then(function (res) { if (res.ok) { // response okay! return the response and the canonical url we found return fulfill({ response: res, url: url }); } else { if (res.status == 301 || res.status == 308) { // permanent redirect means we use this new url as canonical // so, recurse on the new url! _this.getUrlWithRedirects(res.headers.get('location')).then(function (result) { return fulfill(result); }).catch(function (err) { return reject(err); }); } else if (res.status == 302 || res.status == 307) { // temporary redirect means we use the new url for discovery, but // don't treat it as canonical var followUrl = res.headers.get('location'); fetch(followUrl).then(function (res) { if (res.ok) { return fulfill({ response: res, url: followUrl }); } else { return reject(iauthnError('Error getting page', res.status, followUrl)); } }); } else { return reject(iauthnError('Error getting page', res.status, url)); } } }).catch(function (err) { reject(iauthnError('Error fetching ' + url, null, err)); }); }); } /** * Get the authorization endpoint needed from the given url * @param {string} url The url to scrape * @return {Promise} Passes an object of endpoints on success based on relEndpoints mapping */ }, { key: 'getEndpointsFromUrl', value: function getEndpointsFromUrl(url) { var _this2 = this; return new Promise(function (fulfill, reject) { var endpoints = {}; var rels_to_endpoints = _this2.options.relEndpoints; // Make sure the url is canonicalized url = _this2.getCanonicalUrl(url); var baseUrl = url; _this2.getUrlWithRedirects(url).then(function (result) { var res = result.response; url = result.url; baseUrl = result.url; // Check for endpoints in headers var linkHeaders = res.headers.get('link'); if (linkHeaders) { var links = linkHeaders.split(','); links.forEach(function (link) { Object.keys(rels_to_endpoints).forEach(function (key) { var rel = link.match(/rel=("([^"]*)"|([^,"<]+))/); if (rel && rel[1] && (' ' + rel[1].toLowerCase() + ' ').indexOf(' ' + key + ' ') >= 0) { var linkValues = link.match(/[^<>|\s]+/g); if (linkValues && linkValues[0]) { var endpointUrl = linkValues[0]; endpointUrl = new URL(endpointUrl, url).toString(); endpoints[rels_to_endpoints[key]] = endpointUrl; } } }); }); } return res.text(); }).then(function (html) { // Get rel links var rels = relScraper(html, baseUrl); // Save necessary endpoints. _this2.options.me = url; if (rels) { Object.keys(rels_to_endpoints).forEach(function (key) { if (rels[key] && rels[key][0]) { endpoints[rels_to_endpoints[key]] = rels[key][0]; } }); } var endpoint_keys = Object.keys(endpoints); if (endpoint_keys.length > 0) { // duplicate into this.options for later reference _this2.options.endpoints = endpoints; // keep backwards-compatible entries: var authEndpointKey = rels_to_endpoints['authorization_endpoint']; if (endpoints[authEndpointKey]) { _this2.options.authEndpoint = endpoints[authEndpointKey]; } var tokenEndpointKey = rels_to_endpoints['token_endpoint']; if (endpoints[tokenEndpointKey]) { _this2.options.tokenEndpoint = endpoints[tokenEndpointKey]; } var micropubEndpointKey = rels_to_endpoints['micropub']; if (endpoints[micropubEndpointKey]) { _this2.options.micropubEndpoint = endpoints[micropubEndpointKey]; } return fulfill(endpoints); } return reject(iauthnError('Error getting authorization header data')); }).catch(function (err) { return reject(err); }); }); } }, { key: 'verifyCode', value: function verifyCode(code) { var _this3 = this; return new Promise(function (fulfill, reject) { var requiredOptions = ['me', 'clientId', 'redirectUri']; if (_this3.options.scope) { requiredOptions = requiredOptions.concat(['tokenEndpoint']); } else { requiredOptions = requiredOptions.concat(['authEndpoint']); } var requirements = _this3.checkRequiredOptions(requiredOptions); if (!requirements.pass) { return reject(iauthnError('Missing required options: ' + requirements.missing.join(', '))); } var data = { code: code, client_id: _this3.options.clientId, redirect_uri: _this3.options.redirectUri }; var endpoint = _this3.options.authEndpoint; if (_this3.options.scope) { data = data.assign({ grant_type: 'authorization_code' }); endpoint = _this3.options.tokenEndpoint; } var request = { method: 'POST', body: qsStringify(data), headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', Accept: 'application/json, application/x-www-form-urlencoded' } // mode: 'cors', }; fetch(endpoint, request).then(function (res) { if (!res.ok) { return reject(iauthnError('Error validating auth code', res.status)); } var contentType = res.headers.get('Content-Type'); if (contentType && contentType.indexOf('application/json') === 0) { return res.json(); } else { return res.text(); } }).then(function (result) { // Parse the response from the indieauth server if (typeof result === 'string') { result = qsParse(result); } if (result.error_description) { return reject(iauthnError(result.error_description)); } else if (result.error) { return reject(iauthnError(result.error)); } if (!result.me) { return reject(iauthnError('The auth endpoint did not return the expected parameters')); } // Check me is the same (removing any trailing slashes) if (result.me && result.me.replace(/\/+$/, '') !== _this3.options.me.replace(/\/+$/, '')) { return reject(iauthnError('The me values did not match')); } // Successfully verified the code // FIXME: if scope, send back the token, too! fulfill(result.me); }).catch(function (err) { return reject(iauthnError('Error verifying authorization code', null, err)); }); }); } /** * Get the authentication url based on the set options * @return {string|boolean} The authentication url or false on missing options */ }, { key: 'getAuthUrl', value: function getAuthUrl() { var _this4 = this; return new Promise(function (fulfill, reject) { var requirements = _this4.checkRequiredOptions(['me']); if (!requirements.pass) { return reject(iauthnError('Missing required options: ' + requirements.missing.join(', '))); } _this4.getEndpointsFromUrl(_this4.options.me).then(function () { var requirements = _this4.checkRequiredOptions(['me', 'clientId', 'redirectUri']); if (!requirements.pass) { return reject(iauthnError('Missing required options: ' + requirements.missing.join(', '))); } var authParams = { me: _this4.options.me, client_id: _this4.options.clientId, redirect_uri: _this4.options.redirectUri, state: _this4.options.state }; if (_this4.options.scope) { // if there's a scope, we'll request a code to exchange for an // access token. authParams['scope'] = _this4.options.scope; authParams['response_type'] = 'code'; } else { // otherwise we just want to auth this user authParams['response_type'] = 'id'; } fulfill(_this4.options.authEndpoint + '?' + qsStringify(authParams)); }).catch(function (err) { return reject(iauthnError('Error getting auth url', null, err)); }); }); } }]); return IndieAuthentication; }(); exports.default = IndieAuthentication; module.exports = exports['default'];