MAJOR: Updates for Authenticated Web UI and CLI #30

Open
coolaj86 wants to merge 77 commits from next into master
5 changed files with 282 additions and 100 deletions
Showing only changes of commit e6b7ba575f - Show all commits

View File

@ -338,9 +338,12 @@ controllers.relay = function (req, res, opts) {
return;
}
console.log('POST /api/relay:');
console.log(opts.body);
console.log();
return urequestAsync(opts.body).then(function (resp) {
res.setHeader('Content-Type', 'application/json');
var resp = resp.toJSON();
resp = resp.toJSON();
res.end(JSON.stringify(resp));
});
};

View File

@ -104,6 +104,7 @@
<script src="/js/vue.js"></script>
<script src="/js/telebit.js"></script>
<script src="/js/telebit-token.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View File

@ -5,7 +5,7 @@ var Vue = window.Vue;
var Telebit = window.TELEBIT;
var api = {};
/*globals AbortController*/
/*
function safeFetch(url, opts) {
var controller = new AbortController();
var tok = setTimeout(function () {
@ -19,28 +19,29 @@ function safeFetch(url, opts) {
clearTimeout(tok);
});
}
*/
api.config = function apiConfig() {
return safeFetch("/api/config", {
method: "GET"
return Telebit.reqLocalAsync({
url: "/api/config"
, method: "GET"
}).then(function (resp) {
return resp.json().then(function (json) {
appData.config = json;
return json;
});
var json = resp.body;
appData.config = json;
return json;
});
};
api.status = function apiStatus() {
return safeFetch("/api/status", { method: "GET" }).then(function (resp) {
return resp.json().then(function (json) {
appData.status = json;
return json;
});
return Telebit.reqLocalAsync({ url: "/api/status", method: "GET" }).then(function (resp) {
var json = resp.body;
appData.status = json;
return json;
});
};
api.initialize = function apiInitialize() {
var opts = {
method: "POST"
url: "/api/init"
, method: "POST"
, headers: {
'Content-Type': 'application/json'
}
@ -48,14 +49,13 @@ api.initialize = function apiInitialize() {
foo: 'bar'
})
};
return safeFetch("/api/init", opts).then(function (resp) {
return resp.json().then(function (json) {
appData.initResult = json;
window.alert("Error: [success] " + JSON.stringify(json, null, 2));
return json;
}).catch(function (err) {
window.alert("Error: [init] " + (err.message || JSON.stringify(err, null, 2)));
});
return Telebit.reqLocalAsync(opts).then(function (resp) {
var json = resp.body;
appData.initResult = json;
window.alert("Error: [success] " + JSON.stringify(json, null, 2));
return json;
}).catch(function (err) {
window.alert("Error: [init] " + (err.message || JSON.stringify(err, null, 2)));
});
};
@ -104,15 +104,27 @@ var appMethods = {
return;
}
if (-1 !== TELEBIT_RELAYS.indexOf(appData.init.relay)) {
return api.initialize();
if (!telebitState.config) { telebitState.config = {}; }
if (!telebitState.config.relay) { telebitState.config.relay = telebitState.relay; }
telebitState.config.email = appData.init.email;
telebitState.config._otp = Telebit.otp();
return Telebit.authorize(telebitState).then(function () {
console.log('1 api.init...');
return api.initialize();
}).catch(function (err) {
console.error(err);
window.alert("Error: [authorize] " + (err.message || JSON.stringify(err, null, 2)));
});
} else {
changeState('advanced');
}
}).catch(function (err) {
console.error(err);
window.alert("Error: [directory] " + (err.message || JSON.stringify(err, null, 2)));
});
}
, advance: function () {
console.log('2 api.init...');
return api.initialize();
}
, productionAcme: function () {

View File

@ -0,0 +1,123 @@
;(function (exports) {
'use strict';
/* global Promise */
var PromiseA;
if ('undefined' !== typeof Promise) {
PromiseA = Promise;
} else {
throw new Error("no Promise implementation defined");
}
var common = exports.TELEBIT || require('./lib/common.js');
common.authorize = common.getToken = function getToken(state) {
state.relay = state.config.relay;
// { _otp, config: {} }
return common.api.token(state, {
error: function (err) {
console.error("[Error] common.api.token handlers.error:");
console.error(err);
return PromiseA.reject(err);
}
, directory: function (dir) {
//console.log('[directory] Telebit Relay Discovered:');
//console.log(dir);
state._apiDirectory = dir;
return PromiseA.resolve();
}
, tunnelUrl: function (tunnelUrl) {
//console.log('[tunnelUrl] Telebit Relay Tunnel Socket:', tunnelUrl);
state.wss = tunnelUrl;
return PromiseA.resolve();
}
, requested: function (authReq) {
console.log("[requested] Pairing Requested");
state.config._otp = state.config._otp = authReq.otp;
if (!state.config.token && state._can_pair) {
console.info("0000".replace(/0000/g, state.config._otp));
}
return PromiseA.resolve();
}
, connect: function (pretoken) {
console.log("[connect] Enabling Pairing Locally...");
state.config.pretoken = pretoken;
state._connecting = true;
return common.reqLocalAsync({ url: '/api/config', method: 'POST', data: state.config || {} }).then(function () {
console.info("waiting...");
return PromiseA.resolve();
}).catch(function (err) {
state._error = err;
console.error("Error while initializing config [connect]:");
console.error(err);
return PromiseA.reject(err);
});
}
, offer: function (token) {
//console.log("[offer] Pairing Enabled by Relay");
state.config.token = token;
if (state._error) {
return;
}
state._connecting = true;
try {
//require('jsonwebtoken').decode(token);
token = token.split('.');
token[0] = token[0].replace(/_/g, '/').replace(/-/g, '+');
while (token[0].length % 4) { token[0] += '='; }
btoa(token[0]);
token[1] = token[1].replace(/_/g, '/').replace(/-/g, '+');
while (token[1].length % 4) { token[1] += '='; }
btoa(token[1]);
//console.log(require('jsonwebtoken').decode(token));
} catch(e) {
console.warn("[warning] could not decode token");
}
return common.reqLocalAsync({ url: '/api/config', method: 'POST', data: state.config }).then(function () {
//console.log("Pairing Enabled Locally");
return PromiseA.resolve();
}).catch(function (err) {
state._error = err;
console.error("Error while initializing config [offer]:");
console.error(err);
return PromiseA.reject(err);
});
}
, granted: function (/*_*/) {
//console.log("[grant] Pairing complete!");
return PromiseA.resolve();
}
, end: function () {
return common.reqLocalAsync({ url: '/api/enable', method: 'POST', data: [] }).then(function () {
console.info("Success");
// workaround for https://github.com/nodejs/node/issues/21319
if (state._useTty) {
setTimeout(function () {
console.info("Some fun things to try first:\n");
console.info(" ~/telebit http ~/public");
console.info(" ~/telebit tcp 5050");
console.info(" ~/telebit ssh auto");
console.info();
console.info("Press any key to continue...");
console.info();
process.exit(0);
}, 0.5 * 1000);
return;
}
// end workaround
//parseCli(state);
}).catch(function (err) {
console.error('[end] [error]', err);
return PromiseA.reject(err);
});
}
});
};
}('undefined' === typeof module ? window : module.exports));

View File

@ -2,6 +2,7 @@
'use strict';
var common = exports.TELEBIT = {};
common.debug = true;
/* global Promise */
var PromiseA;
@ -14,19 +15,6 @@ if ('undefined' !== typeof Promise) {
/*globals AbortController*/
if ('undefined' !== typeof fetch) {
common.requestAsync = function (opts) {
/*
if (opts.json && true !== opts.json) {
opts.body = opts.json;
}
if (opts.json) {
if (!opts.headers) { opts.headers = {}; }
if (opts.body) {
opts.headers['Content-Type'] = 'application/json';
} else {
opts.headers.Accepts = 'application/json';
}
}
*/
// funnel requests through the local server
// (avoid CORS, for now)
var relayOpts = {
@ -49,13 +37,6 @@ if ('undefined' !== typeof fetch) {
return window.fetch(relayOpts.url, relayOpts).then(function (resp) {
clearTimeout(tok);
return resp.json().then(function (json) {
/*
var headers = {};
resp.headers.forEach(function (k, v) {
headers[k] = v;
});
return { statusCode: resp.status, headers: headers, body: json };
*/
if (json.error) {
return PromiseA.reject(new Error(json.error && json.error.message || JSON.stringify(json.error)));
}
@ -63,8 +44,38 @@ if ('undefined' !== typeof fetch) {
});
});
};
common.reqLocalAsync = function (opts) {
if (!opts) { opts = {}; }
if (opts.json && true !== opts.json) {
opts.body = opts.json;
}
if (opts.json) {
if (!opts.headers) { opts.headers = {}; }
if (opts.body) {
opts.headers['Content-Type'] = 'application/json';
} else {
opts.headers.Accepts = 'application/json';
}
}
var controller = new AbortController();
var tok = setTimeout(function () {
controller.abort();
}, 4000);
opts.signal = controller.signal;
return window.fetch(opts.url, opts).then(function (resp) {
clearTimeout(tok);
return resp.json().then(function (json) {
var headers = {};
resp.headers.forEach(function (k, v) {
headers[k] = v;
});
return { statusCode: resp.status, headers: headers, body: json };
});
});
};
} else {
common.requestAsync = require('util').promisify(require('@coolaj86/urequest'));
common.reqLocalAsync = require('util').promisify(require('@coolaj86/urequest'));
}
common.parseUrl = function (hostname) {
@ -78,7 +89,12 @@ common.parseUrl = function (hostname) {
return hostname;
};
common.parseHostname = function (hostname) {
var location = new URL(hostname);
var location = {};
try {
location = new URL(hostname);
} catch(e) {
// ignore
}
if (!location.protocol || /\./.test(location.protocol)) {
hostname = 'https://' + hostname;
location = new URL(hostname);
@ -109,6 +125,17 @@ common.signToken = function (state) {
return jwt.sign(tokenData, state.config.secret);
};
common.promiseTimeout = function (ms) {
var x = new PromiseA(function (resolve) {
x._tok = setTimeout(function () {
resolve();
}, ms);
});
x.cancel = function () {
clearTimeout(x._tok);
};
return x;
};
common.api = {};
common.api.directory = function (state) {
console.log('[DEBUG] state:');
@ -118,11 +145,14 @@ common.api.directory = function (state) {
if (state._relays[state._relayUrl]) {
return PromiseA.resolve(state._relays[state._relayUrl]);
}
console.error('aaaaaaaaabsnthsnth');
return common.requestAsync({ url: state._relayUrl + common.apiDirectory, json: true }).then(function (resp) {
console.error('123aaaaaaaaabsnthsnth');
var dir = resp.body;
state._relays[state._relayUrl] = dir;
return dir;
}).catch(function (err) {
console.error('bsnthsnth');
return PromiseA.reject(err);
});
};
@ -133,18 +163,18 @@ common.api._parseWss = function (state, dir) {
state._relayHostname = common.parseHostname(state.relay);
return dir.tunnel.method + '://' + dir.api_host.replace(/:hostname/g, state._relayHostname) + dir.tunnel.pathname;
};
common.api.wss = function (state, cb) {
common.api.directory(state).then(function (dir) {
cb(null, common.api._parseWss(state, dir));
}).catch(cb);
common.api.wss = function (state) {
return common.api.directory(state).then(function (dir) {
return common.api._parseWss(state, dir);
});
};
common.api.token = function (state, handlers) {
// directory, requested, connect, tunnelUrl, offer, granted, end
function afterDir(err, dir) {
function afterDir(dir) {
if (common.debug) { console.log('[debug] after dir'); }
state.wss = common.api._parseWss(state, dir);
handlers.tunnelUrl(state.wss, function () {
return PromiseA.resolve(handlers.tunnelUrl(state.wss)).then(function () {
if (common.debug) { console.log('[debug] after tunnelUrl'); }
if (state.config.secret /* && !state.config.token */) {
state.config._token = common.signToken(state);
@ -153,21 +183,19 @@ common.api.token = function (state, handlers) {
if (state.token) {
if (common.debug) { console.log('[debug] token via token or secret'); }
// { token, pretoken }
handlers.connect(state.token, function () {
handlers.end(null, function () {});
return PromiseA.resolve(handlers.connect(state.token)).then(function () {
return PromiseA.resolve(handlers.end(null));
});
return;
}
// backwards compat (TODO remove)
if (err || !dir || !dir.pair_request) {
if (!dir.pair_request) {
if (common.debug) { console.log('[debug] no dir, connect'); }
handlers.error(new Error("No token found or generated, and no pair_request api found."));
return;
return PromiseA.resolve(handlers.error(err || new Error("No token found or generated, and no pair_request api found.")));
}
// TODO sign token with own private key, including public key and thumbprint
// (much like ACME JOSE account)
// TODO handle agree
var otp = state.config._otp; // common.otp();
var authReq = {
subject: state.config.email
@ -187,8 +215,21 @@ common.api.token = function (state, handlers) {
*/
};
var pairRequestUrl = new URL(dir.pair_request.pathname, 'https://' + dir.api_host.replace(/:hostname/g, state._relayHostname));
console.log('pairRequestUrl:', pairRequestUrl);
//console.log('pairRequestUrl:', JSON.stringify(pairRequestUrl.toJSON()));
var req = {
url: pairRequestUrl
// WHATWG URL defines .toJSON() but, of course, it's not implemented
// because... why would we implement JavaScript objects in the DOM
// when we can have perfectly incompatible non-JS objects?
url: {
host: pairRequestUrl.host
, hostname: pairRequestUrl.hostname
, href: pairRequestUrl.href
, pathname: pairRequestUrl.pathname
, port: pairRequestUrl.port
, protocol: pairRequestUrl.protocol
, search: pairRequestUrl.search
}
, method: dir.pair_request.method
, json: authReq
};
@ -198,7 +239,7 @@ common.api.token = function (state, handlers) {
function gotoNext(req) {
if (common.debug) { console.log('[debug] gotoNext called'); }
if (common.debug) { console.log(req); }
common.requestAsync(req).then(function (resp) {
return common.requestAsync(req).then(function (resp) {
var body = resp.body;
function checkLocation() {
@ -207,86 +248,88 @@ common.api.token = function (state, handlers) {
// pending, try again
if ('pending' === body.status && resp.headers.location) {
if (common.debug) { console.log('[debug] pending'); }
setTimeout(gotoNext, 2 * 1000, { url: resp.headers.location, json: true });
return;
}
if ('ready' === body.status) {
return common.promiseTimeout(2 * 1000).then(function () {
return gotoNext({ url: resp.headers.location, json: true });
});
} else if ('ready' === body.status) {
if (common.debug) { console.log('[debug] ready'); }
if (firstReady) {
if (common.debug) { console.log('[debug] first ready'); }
firstReady = false;
state.token = body.access_token;
state.config.token = state.token;
handlers.offer(body.access_token, function () {
// falls through on purpose
PromiseA.resolve(handlers.offer(body.access_token)).then(function () {
/*ignore*/
});
}
setTimeout(gotoNext, 2 * 1000, req);
return;
}
if ('complete' === body.status) {
if (common.debug) { console.log('[debug] complete'); }
handlers.granted(null, function () {
handlers.end(null, function () {});
return common.promiseTimeout(2 * 1000).then(function () {
return gotoNext(req);
});
return;
} else if ('complete' === body.status) {
if (common.debug) { console.log('[debug] complete'); }
return PromiseA.resolve(handlers.granted(null)).then(function () {
return PromiseA.resolve(handlers.end(null)).then(function () {});
});
} else {
if (common.debug) { console.log('[debug] bad status'); }
var err = new Error("Bad State:" + body.status);
err._request = req;
return PromiseA.resolve(handlers.error(err));
}
if (common.debug) { console.log('[debug] bad status'); }
var err = new Error("Bad State:" + body.status);
err._request = req;
handlers.error(err, function () {});
}
if (firstReq) {
if (common.debug) { console.log('[debug] first req'); }
handlers.requested(authReq, function () {
handlers.connect(body.access_token || body.jwt, function () {
if (!body.access_token && !body.jwt) {
return PromiseA.reject(new Error("something wrong with pre-authorization request"));
}
firstReq = false;
return PromiseA.resolve(handlers.requested(authReq)).then(function () {
return PromiseA.resolve(handlers.connect(body.access_token || body.jwt)).then(function () {
var err;
if (!resp.headers.location) {
err = new Error("bad authentication request response");
err._resp = resp.toJSON && resp.toJSON();
handlers.error(err, function () {});
return;
return PromiseA.resolve(handlers.error(err)).then(function () {});
}
setTimeout(gotoNext, 2 * 1000, { url: resp.headers.location, json: true });
return common.promiseTimeout(2 * 1000).then(function () {
return gotoNext({ url: resp.headers.location, json: true });
});
});
});
firstReq = false;
return;
} else {
if (common.debug) { console.log('[debug] other req'); }
checkLocation();
return checkLocation();
}
}).catch(function (err) {
if (common.debug) { console.log('[debug] gotoNext error'); }
err._request = req;
err._hint = '[telebitd.js] pair request';
handlers.error(err, function () {});
return PromiseA.resolve(handlers.error(err)).then(function () {});
});
}
gotoNext(req);
return gotoNext(req);
});
}
// backwards compat (TODO verify we can remove this)
var failoverDir = '{ "api_host": ":hostname", "tunnel": { "method": "wss", "pathname": "" } }';
common.api.directory(state).then(function (dir) {
if (!dir.api_host) {
dir = JSON.parse(failoverDir);
return afterDir(null, dir);
}
handlers.directory(dir).then(function (dir) {
return afterDir(null, dir);
}).catch(function (err) {
return PromiseA.reject(err);
});
return common.api.directory(state).then(function (dir) {
console.log('[debug] [directory]', dir);
if (!dir.api_host) { dir = JSON.parse(failoverDir); }
return dir;
}).catch(function (err) {
return afterDir(err, JSON.parse(failoverDir));
console.warn('[warn] [directory] fetch fail, using failover');
console.warn(err);
return JSON.parse(failoverDir);
}).then(function (dir) {
return PromiseA.resolve(handlers.directory(dir)).then(function () {
console.log('[debug] [directory]', dir);
return afterDir(dir);
});
});
};