diff --git a/lib/extensions/admin/account.html b/lib/extensions/admin/account.html
index 94f5f3d..33a7aa2 100644
--- a/lib/extensions/admin/account.html
+++ b/lib/extensions/admin/account.html
@@ -3,8 +3,9 @@
Telebit Account
+ Login
@@ -19,37 +20,44 @@
});
var $ = function () { return document.querySelector.apply(document, arguments); }
- function onChangeProvider(providerUri) {
- // example https://oauth3.org
- return oauth3.setIdentityProvider(providerUri);
- }
+ function onChangeProvider(providerUri) {
+ // example https://oauth3.org
+ return oauth3.setIdentityProvider(providerUri);
+ }
- // This opens up the login window for the specified provider
- //
- function onClickLogin(ev) {
+ // This opens up the login window for the specified provider
+ //
+ function onClickLogin(ev) {
ev.preventDefault();
ev.stopPropagation();
+ var email = $('.js-auth-subject').value;
+
// TODO check subject for provider viability
return oauth3.authenticate({
- subject: $('.js-auth-subject').value
+ subject: email
+ , scope: 'email@oauth3.org'
}).then(function (session) {
- console.info('Authentication was Successful:');
- console.log(session);
+ console.info('Authentication was Successful:');
+ console.log(session);
- // You can use the PPID (or preferably a hash of it) as the login for your app
- // (it securely functions as both username and password which is known only by your app)
- // If you use a hash of it as an ID, you can also use the PPID itself as a decryption key
- //
- console.info('Secure PPID (aka subject):', session.token.sub);
+ // You can use the PPID (or preferably a hash of it) as the login for your app
+ // (it securely functions as both username and password which is known only by your app)
+ // If you use a hash of it as an ID, you can also use the PPID itself as a decryption key
+ //
+ console.info('Secure PPID (aka subject):', session.token.sub);
- return oauth3.request({
- url: 'https://api.oauth3.org/api/issuer@oauth3.org/jwks/:sub/:kid.json'
+ function listStuff() {
+ window.alert("TODO: show authorized devices, domains, and connectivity information");
+ }
+
+ return oauth3.request({
+ url: 'https://api.oauth3.org/api/issuer@oauth3.org/jwks/:sub/:kid.json'
.replace(/:sub/g, session.token.sub)
.replace(/:kid/g, session.token.iss)
- , session: session
- }).then(function (resp) {
+ , session: session
+ }).then(function (resp) {
console.info("Public Key:");
console.log(resp.data);
@@ -62,25 +70,44 @@
console.log(resp.data);
return oauth3.request({
- url: 'https://api.telebit.cloud/api/telebit.cloud/account'
+ url: 'https://api.' + location.hostname + '/api/telebit.cloud/account'
, session: session
}).then(function (resp) {
console.info("Telebit Account:");
console.log(resp.data);
+ if (1 === resp.data.accounts.length) {
+ listStuff(resp);
+ } else if (0 === resp.data.accounts.length) {
+ return oauth3.request({
+ url: 'https://api.' + location.hostname + 'api/telebit.cloud/account'
+ , method: 'POST'
+ , session: session
+ , body: {
+ email: email
+ }
+ }).then(function (resp) {
+ listStuff(resp);
+ });
+ } if (resp.data.accounts.length > 2) {
+ window.alert("Multiple accounts.");
+ } else {
+ window.alert("Bad response.");
+ }
+
});
});
- });
+ });
- }, function (err) {
- console.error('Authentication Failed:');
- console.log(err);
- });
- }
+ }, function (err) {
+ console.error('Authentication Failed:');
+ console.log(err);
+ });
+ }
$('body form.js-auth-form').addEventListener('submit', onClickLogin);
onChangeProvider('oauth3.org');
diff --git a/lib/extensions/index.js b/lib/extensions/index.js
index 653c0a0..9ed9727 100644
--- a/lib/extensions/index.js
+++ b/lib/extensions/index.js
@@ -1,6 +1,14 @@
'use strict';
+var PromiseA;
+try {
+ PromiseA = require('bluebird');
+} catch(e) {
+ PromiseA = global.Promise;
+}
+
var fs = require('fs');
+var sfs = require('safe-replace').create({ tmp: 'tmp', bak: 'bak' });
var path = require('path');
var util = require('util');
var crypto = require('crypto');
@@ -9,13 +17,206 @@ var jwt = require('jsonwebtoken');
var requestAsync = util.promisify(require('@coolaj86/urequest'));
var readFileAsync = util.promisify(fs.readFile);
var mkdirpAsync = util.promisify(require('mkdirp'));
+var TRUSTED_ISSUERS = [ 'oauth3.org' ];
+var DB = {};
+DB._load = function () {
+ try {
+ DB._perms = require('./permissions.json');
+ } catch(e) {
+ try {
+ DB._perms = require('./permissions.json.bak');
+ } catch(e) {
+ DB._perms = [];
+ }
+ }
+ DB._byDomain = {};
+ DB._byPort = {};
+ DB._byEmail = {};
+ DB._byPpid = {};
+ DB._byId = {};
+ DB._grants = {};
+ DB._perms.forEach(function (acc) {
+ if (acc.id) {
+ DB._byId[acc.id] = acc;
+ if (!DB._grants[acc.id]) {
+ DB._grants[acc.id] = [];
+ }
+ acc.domains.forEach(function (d) {
+ DB._grants[d.name + '|id|' + acc.id] = true
+ DB._grants[acc.id].push(d);
+ });
+ acc.ports.forEach(function (p) {
+ DB._grants[p.number + '|id|' + acc.id] = true
+ DB._grants[acc.id].push(p);
+ });
+ }
+ acc.nodes.forEach(function (node) {
+ if ('mailto' === node.scheme || 'email' === node.type) {
+ if (!DB._grants[node.email]) {
+ DB._grants[node.email] = [];
+ }
+ acc.domains.forEach(function (d) {
+ DB._grants[d.name + '|' + (node.scheme||node.type) + '|' + node.name] = true
+ DB._grants[node.email].push(d);
+ });
+ acc.ports.forEach(function (d) {
+ DB._grants[d.name + '|' + (node.scheme||node.type) + '|' + node.name] = true
+ DB._grants[node.email].push(p);
+ });
+ DB._byEmail[node.name] = {
+ account: acc
+ , node: node
+ }
+ }
+ });
+ acc.ppids.forEach(function (node) {
+ DB._byPpid[node.name] = {
+ account: acc
+ , node: node
+ }
+ });
+ acc.domains.forEach(function (domain) {
+ if (DB._byDomain[domain.name]) {
+ console.warn("duplicate domain '" + domain.name + "'");
+ console.warn("::existing account '" + acc.nodes.map(function (node) { return node.name; }) + "'");
+ console.warn("::new account '" + DB._byDomain[domain.name].account.nodes.map(function (node) { return node.name; }) + "'");
+ }
+ DB._byDomain[domain.name] = {
+ account: acc
+ , domain: domain
+ };
+ });
+ acc.ports.forEach(function (port) {
+ if (DB._byPort[port.number]) {
+ console.warn("duplicate port '" + domain.number + "'");
+ console.warn("::existing account '" + acc.nodes.map(function (node) { return node.name; }) + "'");
+ console.warn("::new account '" + DB._byPort[port.number].account.nodes.map(function (node) { return node.name; }) + "'");
+ }
+ DB._byPort[domain.name] = {
+ account: acc
+ , port: port
+ };
+ });
+ });
+};
+DB._load();
+DB.accounts = {};
+DB.accounts.get = function (obj) {
+ return PromiseA.resolve().then(function () {
+ return DB._byId[obj.name] || (DB._byEmail[obj.name] || {}).acc || null;
+ });
+};
+DB.accounts.add = function (obj) {
+ return PromiseA.resolve().then(function () {
+ if (obj.id) {
+ // TODO more checks
+ DB._perms.push(obj);
+ } else if (obj.email) {
+ obj.email = undefined;
+ DB._perms.push(obj);
+ }
+ });
+};
+DB.domains = {};
+DB.domains.available = function (name) {
+ return PromiseA.resolve().then(function () {
+ return !DB._byDomain[name];
+ });
+};
+DB.domains._add = function (acc, name) {
+ // TODO verifications to change ownership of a domain
+ return PromiseA.resolve().then(function () {
+ var err;
+ //var acc = DB._byId[aid];
+ var domain = {
+ name: name
+ , createdAt: new Date().toISOString()
+ , wildcard: true
+ };
+ var pdomain;
+ var parts = name.split('.').map(function (el, i) {
+ return arr.slice(i).join('.');
+ }).reverse();
+ parts.shift();
+ parts.pop();
+ if (parts.some(function (part) {
+ if (DB._byDomain[part]) {
+ pdomain = part;
+ return true;
+ }
+ })) {
+ err = new Error("'" + name + "' exists as '" + pdomain + "' and therefore requires an admin to review and approve");
+ err.code = "E_REQ_ADMIN";
+ throw err;
+ }
+ if (DB._byDomain[name]) {
+ if (acc !== DB._byDomain[name].account) {
+ throw new Error("domain '" + name + "' exists");
+ }
+ // happily ignore non-change
+ return;
+ }
+ DB._byDomain[name] = {
+ account: acc
+ , domain: domain
+ };
+ acc.domains.push(domain);
+ });
+};
+DB.ports = {};
+DB.ports.available = function (number) {
+ return PromiseA.resolve().then(function () {
+ return !DB._byPort[number];
+ });
+};
+DB.ports._add = function (acc, number) {
+ return PromiseA.resolve().then(function () {
+ //var acc = DB._byId[aid];
+ var port = {
+ number: number
+ , createdAt: new Date().toISOString()
+ };
+ if (DB._byPort[number]) {
+ // TODO verifications
+ throw new Error("port '" + number + "' exists");
+ }
+ DB._byPort[number] = {
+ account: acc
+ , domain: domain
+ };
+ acc.domains.push(domain);
+ });
+};
+DB._save = function () {
+ return sfs.writeAsync('./accounts.json', JSON.stringify(DB._perms));
+};
+DB._saveToken = null;
+DB._savePromises = [];
+DB._savePromise = PromiseA.resolve();
+DB.save = function () {
+ cancelTimeout(DB._saveToken);
+ return new Promise(function (resolve, reject) {
+ function doSave() {
+ DB._savePromise = DB._savePromise.then(function () {
+ return DB._save().then(function (yep) {
+ DB._savePromises.forEach(function (p) {
+ p.resolve(yep);
+ });
+ DB._savePromises.length = 1;
+ }, function (err) {
+ DB._savePromises.forEach(function (p) {
+ p.reject(err);
+ });
+ DB._savePromises.length = 1;
+ });
+ });
+ return DB._savePromise;
+ }
-var PromiseA;
-try {
- PromiseA = require('bluebird');
-} catch(e) {
- PromiseA = global.Promise;
-}
+ DB._saveToken = setTimeout(doSave, 2500);
+ DB.savePromises.push({ resolve: resolve, reject: reject });
+ });
+};
var _auths = module.exports._auths = {};
var Auths = {};
@@ -140,15 +341,22 @@ Accounts.create = function (req) {
Accounts.link = function (req) {
};
*/
-Accounts.getBySub = function (req) {
+
+Accounts.getOrCreate = function (req) {
var id = Accounts._getTokenId(req.auth);
- var subpath = Accounts._subPath(req, id);
- return readFileAsync(path.join(subpath, 'index.json'), 'utf8').then(function (text) {
- return JSON.parse(text);
- }, function (/*err*/) {
- return null;
- }).then(function (links) {
- return links || { id: id, sub: req.auth.sub, iss: req.auth.iss, accounts: [] };
+ var idNode = { type: 'ppid', name: id };
+
+ return DB.accounts.get(idNode).then(function (acc) {
+ if (acc) { return _acc; }
+ acc = { id: id, sub: req.auth.sub, iss: req.auth.iss, domains: [], ports: [], nodes: [ idNode ] };
+ return DB.accounts.add(acc).then(function () {
+ // intentionally not returned to the promise chain
+ DB.save().catch(function (err) {
+ console.error('DB.save() failed:');
+ console.error(err);
+ });
+ return acc;
+ });
});
};
@@ -343,6 +551,7 @@ function oauth3Auth(req, res, next) {
, json: true
}).then(function (resp) {
var jwk = resp.body;
+ console.log('Retrieved token\'s JWK: ', resp.body);
if (200 !== resp.statusCode || 'object' !== typeof resp.body) {
//headers.authorization
res.send({
@@ -362,6 +571,7 @@ function oauth3Auth(req, res, next) {
try {
pubpem = require('jwk-to-pem')(jwk, { private: false });
} catch(e) {
+ console.error("jwk-to-pem", e);
pubpem = null;
}
return verifyJwt(token, pubpem, {
@@ -382,7 +592,7 @@ function oauth3Auth(req, res, next) {
next();
});
});
- }, function (err) {
+ }).catch(function (err) {
res.send({
error: {
code: err.code || "E_GENERIC"
@@ -391,6 +601,13 @@ function oauth3Auth(req, res, next) {
});
});
}
+var OAUTH3 = require('oauth3.js').create({ pathname: process.cwd() });
+/*
+// TODO all of the above should be replace with the official lib
+return OAUTH3.jwk.verifyToken(req.auth.jwt).then(function (token) {
+}).catch(function (err) {
+});
+*/
module.exports.pairRequest = function (opts) {
console.log("It's auth'n time!");
@@ -433,6 +650,50 @@ module.exports.pairRequest = function (opts) {
return authnData;
});
};
+DB.getDomainAndPort = function (state) {
+ var domainCount = 0;
+ var portCount = 0;
+
+ function chooseDomain() {
+ var err;
+ if (domainCount >= 3) {
+ err = new Error("there too few unallocated domains left");
+ err.code = "E_DOMAINS_EXHAUSTED";
+ return PromiseA.reject(err);
+ }
+ domainCount += 1;
+ var hri = require('human-readable-ids').hri;
+ var i = Math.floor(Math.random() * state.config.sharedDomains.length);
+ var hrname = hri.random() + '.' + state.config.sharedDomains[i];
+ return DB.domains.available(hrname).then(function (available) {
+ if (!available) { return chooseDomain(); }
+ return hrname;
+ });
+ }
+ function choosePort() {
+ var err;
+ if (portCount >= 3) {
+ err = new Error("there too few unallocated ports left");
+ err.code = "E_PORTS_EXHAUSTED";
+ return PromiseA.reject(err);
+ }
+ portCount += 1;
+ var portnumber = (1024 + 1) + Math.round(Math.random() * 65535);
+ return DB.ports.available(portnumber).then(function (available) {
+ if (!available) { return portDomain(); }
+ return portnumber;
+ });
+ }
+ return Promise.all([
+ chooseDomain()
+ , choosePort()
+ ]).then(function (two) {
+ return {
+ domain: two[0]
+ , port: two[1]
+ };
+ });
+};
module.exports.pairPin = function (opts) {
var state = opts.state;
return state.Promise.resolve().then(function () {
@@ -455,36 +716,63 @@ module.exports.pairPin = function (opts) {
}
console.log('[pairPin] generating offer');
- var hri = require('human-readable-ids').hri;
- var i = Math.floor(Math.random() * state.config.sharedDomains.length);
- var hrname = hri.random() + '.' + state.config.sharedDomains[i];
- // TODO check used / unused names and ports
- var authzData = {
- id: auth.id
- , domains: [ hrname ]
- , ports: [ (1024 + 1) + Math.round(Math.random() * 65535) ]
- , aud: state.config.webminDomain
- , iat: Math.round(Date.now() / 1000)
- , hostname: auth.hostname
- };
+ return DB.getDomainAndPort(state);
+ }).then(function (grantable) {
+ var emailNode = { scheme: 'mailto', type: 'email', name: auth.subject };
+
+ return DB.accounts.get(emailNode).then(function (_acc) {
+ var acc = _acc;
+ if (!acc) {
+ acc = { email: true, domains: [], ports: [], nodes: [ emailNode ] };
+ }
+ return PromiseA.all([
+ DB.domains._add(acc, opts.domain)
+ , DB.ports._add(acc, opts.port)
+ ]).then(function () {
+ var authzData = {
+ id: auth.id
+ , domains: [ grantable.domain ]
+ , ports: [ grantable.port ]
+ , aud: state.config.webminDomain
+ , iat: Math.round(Date.now() / 1000)
+ // of the client's computer
+ , hostname: auth.hostname
+ };
+ auth.authz = jwt.sign(authzData, state.secret);
+ auth.authzData = authzData;
+ authzData.jwt = auth.authz;
+ auth._offered = authzData;
+ if (auth.resolve) {
+ console.log('[pairPin] resolving');
+ auth.resolve(auth);
+ } else {
+ console.log('[pairPin] not resolvable');
+ }
+
+ if (!_acc) {
+ return DB.accounts.add(acc).then(function () {
+ // intentionally not returned to the promise chain
+ DB.save().catch(function (err) {
+ console.error('DB.save() failed:');
+ console.error(err);
+ });
+ return authzData;
+ });
+ } else {
+ return authzData;
+ }
+ });
+ });
+
+ /*
var pathname = path.join(__dirname, 'emails', auth.subject + '.' + hrname + '.data');
- auth.authz = jwt.sign(authzData, state.secret);
- auth.authzData = authzData;
- authzData.jwt = auth.authz;
- auth._offered = authzData;
- if (auth.resolve) {
- console.log('[pairPin] resolving');
- auth.resolve(auth);
- } else {
- console.log('[pairPin] not resolvable');
- }
fs.writeFile(pathname, JSON.stringify(authzData), function (err) {
if (err) {
console.error('[ERROR] in writing token details');
console.error(err);
}
});
- return authzData;
+ */
});
};
@@ -620,11 +908,81 @@ app.use('/api', CORS({
app.use('/api', bodyParser.json());
app.use('/api/telebit.cloud/account', oauth3Auth);
+Accounts._associateEmails = function (req) {
+ if (-1 === (req._state.config.trustedIssuers||TRUSTED_ISSUERS).indexOf(req.auth.data.iss)) {
+ // again, make sure that untrusted issuers do not get
+ return null;
+ }
+
+ // oauth3.org, issuer@oauth3.org, profile
+ return OAUTH3.request({
+ url: "https://api." + req.auth.data.iss + "/api/issuer@oauth3.org/acl/profile"
+ , session: { accessToken: req.auth.jwt }
+ }).then(function (resp) {
+ var email;
+ var err;
+ (resp.data.nodes||[]).some(function (node) {
+ // TODO use verified email addresses
+ return true
+ });
+ // back-compat for current way email is stored
+ if (!email && /@/.test(resp.data.username)) {
+ email = resp.data.username;
+ }
+ if (!email) {
+ err = new Error ("could not find a verified email address in profile settings");
+ err.code = "E_NO_EMAIL"
+ return PromiseA.reject(err);
+ }
+
+ return [ { scheme: 'mailto', type: 'email', name: email } ];
+ });
+};
app.get('/api/telebit.cloud/account', function (req, res) {
- Accounts.getBySub(req).then(function (subData) {
- res.send(subData);
- }, function (err) {
- res.send({
+ return Accounts.getOrCreate(req).then(function (acc) {
+ var hasEmail = subData.nodes.some(function (node) {
+ return 'email' === node.type;
+ });
+ function getAllGrants() {
+ return PromiseA.all(acc.nodes.map(function (node) {
+ return DB.accounts.get(node);
+ })).then(function (grants) {
+ var domainsMap = {};
+ var portsMap = {};
+ var result = JSON.parse(JSON.stringify(acc));
+ result.domains.length = 0;
+ result.ports.length = 0;
+ grants.forEach(function (account) {
+ account.domains.forEach(function (d) {
+ domainsMap[d.name] = d;
+ });
+ account.ports.forEach(function (p) {
+ portsMap[p.number] = p;
+ });
+ });
+ result.domains = Object.keys(domainsMap).map(function (k) {
+ return domainsMap[k];
+ });
+ result.ports = Object.keys(portsMap).map(function (k) {
+ return portsMap[k];
+ });
+ return result;
+ });
+ }
+ if (!hasEmail) {
+ return Accounts._associateEmails(req).then(function (nodes) {
+ nodes.forEach(function (node) {
+ acc.nodes.push(node);
+ });
+ return getAllGrants();
+ });
+ } else {
+ return getAllGrants();
+ }
+ }).then(function (result) {
+ res.send(result);
+ }).catch(function (err) {
+ return res.send({
error: {
code: err.code || "E_GENERIC"
, message: err.toString()
diff --git a/lib/extensions/package.json b/lib/extensions/package.json
new file mode 100644
index 0000000..6c43a8d
--- /dev/null
+++ b/lib/extensions/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "telebit.commercial",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Commercial node.js APIs for Telebit",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "author": "",
+ "license": "SEE LICENSE IN LICENSE",
+ "dependencies": {
+ "jwk-to-pem": "^2.0.0",
+ "oauth3.js": "^1.2.5"
+ }
+}