indieauth-authentication
Version:
A helper class for creating apps that authenticate via IndieAuth
367 lines (325 loc) • 13.9 kB
JavaScript
;
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'];