From e3aaf421424a187733b08d309b7e6ee35432c213 Mon Sep 17 00:00:00 2001 From: Nikolai Onken Date: Tue, 10 Feb 2015 20:20:05 +0000 Subject: [PATCH] Add loaded and connected handlers --- package.json | 2 +- plugins/c9.ide.editors/document.js | 3 + plugins/c9.login.client/bootstrap.js | 283 +++++++++++++++++++++++++++ plugins/c9.login/legacy.js | 123 ++++++++++++ plugins/c9.login/legacy_strategy.js | 73 +++++++ plugins/c9.login/login.js | 135 +++++++++++++ plugins/c9.login/strategy.js | 45 +++++ 7 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 plugins/c9.login.client/bootstrap.js create mode 100644 plugins/c9.login/legacy.js create mode 100644 plugins/c9.login/legacy_strategy.js create mode 100644 plugins/c9.login/login.js create mode 100644 plugins/c9.login/strategy.js diff --git a/package.json b/package.json index 2719bccc..9d575cc4 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "c9.ide.preview": "#3c4dded23f", "c9.ide.preview.browser": "#be197b0464", "c9.ide.preview.markdown": "#bf952685f6", - "c9.ide.pubsub": "#e6526a20f7", + "c9.ide.pubsub": "#c102844931", "c9.ide.readonly": "#f6f07bbe42", "c9.ide.recentfiles": "#7c099abf40", "c9.ide.remote": "#37773d905b", diff --git a/plugins/c9.ide.editors/document.js b/plugins/c9.ide.editors/document.js index 013a49f0..7dbd1ad8 100644 --- a/plugins/c9.ide.editors/document.js +++ b/plugins/c9.ide.editors/document.js @@ -165,6 +165,9 @@ define(function(require, module, exports) { function progress(options) { emit("progress", options); + if (options.complete) { + emit.sticky("complete"); + } } function clone() { diff --git a/plugins/c9.login.client/bootstrap.js b/plugins/c9.login.client/bootstrap.js new file mode 100644 index 00000000..9f62d313 --- /dev/null +++ b/plugins/c9.login.client/bootstrap.js @@ -0,0 +1,283 @@ +(function(global) { +"use strict"; + +var token = ""; + +var auth = global.auth = function(options) { + // can only be called once + global.auth = null; + + var onLoad = options.onLoad; + var preload = options.preload || noop; + var authorized = options.authorized || noop; + var background = options.background || noop; + + importCssString("html.fulliframe, body.fulliframe {\ + overflow: hidden;\ + margin: auto;\ + height: 100%;\ + width: 100%;\ + }"); + + function noop(callback) { callback(); } + + if (onLoad) { + auth.parallel([ + background, + auth.serial([ + auth.parallel([ + preload, + login + ]), + authorized, + ]) + ])(done); + } + + function login(callback, errback) { + var oauth = new Auth(options.clientId, options.authorizationUrl, options.loginHint); + + oauth.authorize(true, function(err, _token) { + if (err) + return iframeLogin(); + + token = _token.access_token; + callback(null, token); + }); + + function iframeLogin() { + errback && errback(); + oauth.authorize(false, function(err, _token) { + if (err) return callback(err); + token = _token.access_token; + callback(null, token); + }); + } + + return function cancel() { + oauth.cancel(); + }; + } + + function done(err) { + onLoad(err, token); + } + + return { + login: login + }; +}; + +function bindScript(script) { + if (typeof script == "function") + return script; + else + return loadScript.bind(null, script, token); +} + +auth.serial = function(list) { + return function(callback) { + serial(list.map(bindScript), callback); + }; +}; + +auth.parallel = function(list) { + return function(callback) { + parallel(list.map(bindScript), callback); + }; +}; + +function loadScript(path, token, callback) { + var head = document.head || document.getElementsByTagName("head")[0] || document.documentElement; + var s = document.createElement('script'); + + var and = path.indexOf("?") >= 0 ? "&" : "?"; + s.src = path + (token ? and + "access_token=" + encodeURIComponent(token) : ""); + head.appendChild(s); + + s.onload = s.onreadystatechange = function(_, isAbort) { + if (isAbort || !s.readyState || s.readyState == "loaded" || s.readyState == "complete") { + s = s.onload = s.onreadystatechange = null; + if (!isAbort) + callback(); + } + }; +} + +// copied from ace/lib/dom +function importCssString(cssText) { + var style; + + if (document.createStyleSheet) { + style = document.createStyleSheet(); + style.cssText = cssText; + } else { + style = document.createElementNS + ? document.createElementNS("http://www.w3.org/1999/xhtml", "style") + : document.createElement("style"); + + style.appendChild(document.createTextNode(cssText)); + + (document.head || document.getElementsByTagName("head")[0] || document.documentElement).appendChild(style); + } +} + +function serial(handlers, callback) { + (function loop(i) { + if (i >= handlers.length) return callback(); + + handlers[i](function(err) { + if (err) return callback(err); + + loop(i+1); + }); + })(0); +} + +function parallel(handlers, callback) { + var hadErr = false; + var count = 0; + handlers.forEach(function(handler) { + handler(function(err) { + if (hadErr) return; + if (err) { + hadErr = true; + return callback(err); + } + count += 1; + if (count == handlers.length) + return callback(); + }); + }); +} + +// install exactly one global listener +var listeners = {}; +window.addEventListener("message", function(e) { + var token = e.data.token; + if (token) { + for (var url in listeners) { + if (url.indexOf(e.origin) === 0) { + var callback = listeners[url][token.state]; + delete listeners[url][token.state]; + if (callback) callback(null, token); + + // make sure later listeners can't steal the token + e.data.token = null; + break; + } + } + } +}, true); + + +function Auth(clientId, authorizationUrl, loginHint) { + this.clientId = clientId; + this.authorizationUrl = authorizationUrl; + this.loginHint = loginHint; + listeners[authorizationUrl] = {}; +} + +Auth.prototype.authorize = function(immediate, callback) { + if (typeof immediate == "function") + return this.authorize({}, immediate); + + immediate = immediate || false; + + var that = this; + this.state = uid(15); + + var url = this.authorizationUrl + + "?response_type=postmessage" + + "&client_id=" + encodeURIComponent(this.clientId) + + "&state=" + encodeURIComponent(this.state) + + "&style=overlay"; + + if (this.loginHint) + url += "&login_hint=" + encodeURIComponent(this.loginHint || ""); + + if (immediate) + url += "&immediate=1"; + + var frame = this._createFrame(url, immediate); + var timeout = immediate ? 3000 : 0; + + if (timeout) { + var timer = setTimeout(function() { + that._unpoll(); + callback(new Error("Login timed out")); + }, timeout); + } + + this._removeFrame = function() { + clearTimeout(timer); + + frame.parentNode.removeChild(frame); + document.documentElement.className = document.documentElement.className.replace(/\bfulliframe\b/, ""); + document.body.className = document.body.className.replace(/\bfulliframe\b/, ""); + that._removeFrame = null; + }; + + this._poll(function(err, token) { + if (that._removeFrame) + that._removeFrame(); + + if (err) + return callback(err); + + if (token.error) { + err = new Error(token.error); + err.code = token.error_code; + return callback(err); + } + + that.token = token; + return callback(null, token); + }); +}; + +Auth.prototype.cancel = function() { + this._unpoll(); + if (this._removeFrame) + this._removeFrame(); +}; + +Auth.prototype._createFrame = function(url, hidden) { + var frame = document.createElement("iframe"); + frame.setAttribute("src", url); + frame.setAttribute("frameborder", "0"); + if (hidden) { + frame.style.width = "1000px"; + frame.style.height = "1000px"; + frame.style.left = "-10000px"; + } + else { + frame.style.width = "100%"; + frame.style.height = "100%"; + frame.style.zIndex = "300000"; + document.documentElement.className += " fulliframe"; + document.body.className += " fulliframe"; + } + frame.style.position = "absolute"; + document.body.appendChild(frame); + return frame; +}; + +Auth.prototype._poll = function(callback) { + listeners[this.authorizationUrl][this.state] = callback; +}; + +Auth.prototype._unpoll = function() { + delete listeners[this.authorizationUrl][this.state]; +}; + +function uid(length) { + var buf = new Uint8Array(new ArrayBuffer(length)); + (window.crypto || window.msCrypto).getRandomValues(buf); + + return btoa(Array.prototype.reduce.call(buf, function(s, c) { + return s + String.fromCharCode(c); + }, "")).slice(0, length); +} + +})(this); \ No newline at end of file diff --git a/plugins/c9.login/legacy.js b/plugins/c9.login/legacy.js new file mode 100644 index 00000000..aba436fe --- /dev/null +++ b/plugins/c9.login/legacy.js @@ -0,0 +1,123 @@ +"use strict"; + +var assert = require("assert"); +var url = require("url"); +var Cloud9LegayStrategy = require("./legacy_strategy"); +var cookieSignature = require("cookie-signature"); +var decrypt = require("c9/crypt").decrypt; +var login = require("connect-ensure-login"); + +plugin.consumes = [ + "db", + "passport", + "connect.redirect", + "connect.cookieparser", + "session-store" +]; +plugin.provides = ["c9.login"]; + +module.exports = plugin; + +function plugin(options, imports, register) { + assert(options.appId, "Option 'appId' is required"); + assert(options.ideBaseUrl, "Option 'ideBaseUrl' is required"); + assert(options.baseUrl, "Option 'baseUrl' is required"); + assert(options.ssoCookie, "Option 'ssoCookie' is required"); + assert(options.ssoSecret, "Option 'ssoSecret' is required"); + + var db = imports.db; + var passport = imports.passport; + var sessionStore = imports["session-store"]; + + // use the 'proxy' cookie to have federated logout + passport.useStart(function(req, res, next) { + var hash; + // anonymous login + if (!req.cookies || !(hash = req.cookies[options.ssoCookie])) + return done(); + + var encrypted = cookieSignature.unsign(hash, options.ssoSecret); + if (!encrypted) + return done(); + + var sessionId = decrypt(encrypted, options.ssoSecret); + + sessionStore.get(sessionId, function(err, session) { + if (err) return done(err); + done(null, session && session.uid); + }); + + function done(err, ssoUid) { + if (err) return next(err); + ssoUid = ssoUid || -1; + var session = req.session; + if (session && session.passport && session.passport.user && session.passport.user != ssoUid) { + return req.session.regenerate(function(err) { + if (err) return next(err); + + if (session.returnTo) + req.session.returnTo = session.returnTo; + + delete req.user; + next(); + }); + } + else { + if (!req.session.passport) + req.session.passport = {}; + + req.session.passport.user = ssoUid; + next(); + } + } + }); + + var cloud9Strategy = new Cloud9LegayStrategy({ + clientID: options.appId, + ideBaseUrl: options.ideBaseUrl, + callback: options.baseUrl + "/auth/c9l/callback", + db: db + }); + + passport.use(cloud9Strategy); + + passport.section.get("/c9l", passport.authenticate("c9l")); + passport.section.get("/c9l/callback", [ + passport.authenticate("c9l"), + function(req, res, next) { + var user = req.user; + + if (user) { + req.login(user, function(err) { + if (err) return next(err); + res.returnTo(req, "/"); + }); + } + else { + res.redirect("/auth/c9l"); + } + } + ]); + + register(null, { + "c9.login": { + ensureLoggedIn: function() { + return function(req, res, next) { + var redirect = options.baseUrl + "/_auth/c9l"; + var nonce = req.parsedUrl.query.__c9_preview_id__; + + if (nonce) { + redirect += "?nonce=" + encodeURIComponent(nonce); + delete req.parsedUrl.query.__c9_preview_id__; + delete req.parsedUrl.search; + req.originalUrl = url.format(req.parsedUrl); + } + + login.ensureLoggedIn({ + redirectTo: redirect + })(req, res, next); + }; + } + } + }); +} \ No newline at end of file diff --git a/plugins/c9.login/legacy_strategy.js b/plugins/c9.login/legacy_strategy.js new file mode 100644 index 00000000..bdc78f30 --- /dev/null +++ b/plugins/c9.login/legacy_strategy.js @@ -0,0 +1,73 @@ +var passport = require('passport'); +var util = require('util'); +var InternalOAuthError = require("passport-oauth").InternalOAuthError; + + +function Cloud9Legacy(options) { + passport.Strategy.call(this); + this.name = 'c9l'; + + this.clientID = options.clientID; + this.ideBaseUrl = options.ideBaseUrl; + this.callback = options.callback; + this.db = options.db; +} + +/** + * Inherit from `passport.Strategy`. + */ +util.inherits(Cloud9Legacy, passport.Strategy); + +/** + * Authenticate request based on the contents of a HTTP Basic authorization + * header. + * + * @param {Object} req + * @api protected + */ +Cloud9Legacy.prototype.authenticate = function(req, options) { + var that = this; + options = options || {}; + + // the callback handler + if (req.query && req.query.code) { + this.db.AccessToken + .findOne({ + token: req.query.code + }) + .populate("user") + .exec(function(err, token) { + if (err) + return that.error(new InternalOAuthError('failed to obtain access token', err)); + + req.session.token = req.query.code; + that.success(token.user); + }); + return; + } + + var nonce = req.parsedUrl.query.nonce; + if (nonce) { + this.redirect( + this.ideBaseUrl + + "/api/nc/auth" + + "?response_type=nonce" + + "&client_id=" + encodeURIComponent(this.clientID + "_nonce") + + "&nonce=" + encodeURIComponent(nonce) + ); + } + else { + this.redirect( + this.ideBaseUrl + + "/api/nc/auth" + + "?response_type=token" + + "&client_id=" + encodeURIComponent(this.clientID) + + "&login_hint=" + encodeURIComponent(options.loginHint || "") + ); + } +}; + +/** + * Expose `Cloud9Legacy`. + */ +module.exports = Cloud9Legacy; \ No newline at end of file diff --git a/plugins/c9.login/login.js b/plugins/c9.login/login.js new file mode 100644 index 00000000..45d67244 --- /dev/null +++ b/plugins/c9.login/login.js @@ -0,0 +1,135 @@ +"use strict"; + +var assert = require("assert"); +var frontdoor = require("frontdoor"); +var cookie = require("cookie"); +var Passport = require("passport").Passport; +var Cloud9Strategy = require("./strategy"); + +plugin.consumes = [ + "session", + "connect.redirect" +]; +plugin.provides = ["c9.login"]; + +module.exports = plugin; + +function plugin(options, imports, register) { + assert(options.appId, "Option 'appId' is required"); + assert(options.appSecret, "Option 'appSecret' is required"); + assert(options.callback, "Option 'callback' is required"); + assert(options.logout, "Option 'logout' is required"); + assert(options.baseUrl, "Option 'baseUrl' is required"); + assert(options.domain, "Option 'domain' is required"); + assert(options.ssoCookie, "Option 'ssoCookie' is required"); + assert(options.ssoCookie.name, "Option 'ssoCookie.name' is required"); + assert(options.ssoCookie.maxAge, "Option 'ssoCookie.maxAge' is required"); + + var session = imports.session; + var passport = new Passport(); + + session.use(passport.initialize()); + session.use(function(req, res, next) { + passport.session()(req, res, function(err) { + if (err) return next(err); + if (!req.user) return next(); + + var uid = req.cookies[options.ssoCookie.name]; + if (uid != req.user.id) { + req.logout(); + return next(); + } + + next(); + }); + }); + + passport.serializeUser(function(user, done) { + var id; + try { + id = JSON.stringify(user); + } + catch (e) { + return done(e); + } + done(null, id); + }); + + passport.deserializeUser(function(id, done) { + var user; + try { + user = JSON.parse(id); + } + catch (e) { + return done(e); + } + done(null, user); + }); + + var cloud9Strategy = new Cloud9Strategy({ + clientID: options.appId, + clientSecret: options.appSecret, + callbackURL: options.callback, + userProfileURL: options.userProfileURL, + baseUrl: options.baseUrl, + }, function(accessToken, refreshToken, params, profile, done) { + var user = { + id: profile.id, + username: profile.username, + fullname: profile.displayName ? profile.displayName.trim() : profile.username, + token: accessToken + }; + done(null, user); + }); + + passport.use(cloud9Strategy); + + var api = frontdoor(); + passport.section = api.section("auth"); + session.use(api); + + passport.section.get("/logout", function(req, res, next) { + res.redirect(options.baseUrl + "/logout?redirect_uri=" + encodeURIComponent(options.logout)); + }); + passport.section.get("/cloud9", passport.authenticate("cloud9")); + passport.section.get("/cloud9/callback", function(req, res, next) { + passport.authenticate("cloud9", function(err, user, info) { + if (err) return next(err); + + if (user) { + req.login(user, function(err) { + if (err) return next(err); + setCookie(res, req.user.id, options.ssoCookie.maxAge); + res.returnTo(req, "/"); + }); + } + else { + res.redirect("/auth/cloud9"); + } + + })(req, res, next); + }); + + passport.section.get("/cloud9/logout", function(req, res, next) { + req.logout(); + clearCookie(res); + res.redirect("/"); + }); + + function clearCookie(res) { + setCookie(res, "", new Date(1)); + } + function setCookie(res, value, ttl) { + res.setHeader("Set-Cookie", cookie.serialize(options.ssoCookie.name, value, { + domain: "." + options.domain, + path: "/", + expires: ttl instanceof Date ? ttl : new Date(Date.now() + ttl), + secure: true, + httpOnly: true + })); + } + + register(null, { + "c9.login": passport + }); +} \ No newline at end of file diff --git a/plugins/c9.login/strategy.js b/plugins/c9.login/strategy.js new file mode 100644 index 00000000..3443d5e4 --- /dev/null +++ b/plugins/c9.login/strategy.js @@ -0,0 +1,45 @@ +var util = require('util'); +var OAuth2Strategy = require('passport-oauth').OAuth2Strategy; +var InternalOAuthError = require('passport-oauth').InternalOAuthError; + +function Strategy(options, verify) { + options = options || {}; + var baseUrl = options.baseUrl || "https://auth.c9.io/oauth"; + + options.authorizationURL = baseUrl + "/authorize"; + options.tokenURL = baseUrl + "/access_token"; + options.scopeSeparator = ","; + + OAuth2Strategy.call(this, options, verify); + this.name = "cloud9"; + this._userProfileURL = options.userProfileURL || "https://api.c9.io/user"; +} + +util.inherits(Strategy, OAuth2Strategy); + +Strategy.prototype.userProfile = function(accessToken, done) { + this._oauth2.useAuthorizationHeaderforGET(true); + this._oauth2.get(this._userProfileURL, accessToken, function (err, body, res) { + if (err) + return done(new InternalOAuthError('failed to fetch user profile', err)); + + try { + var json = JSON.parse(body); + + var profile = { provider: "cloud9" }; + profile.id = json.id; + profile.displayName = json.name; + profile.username = json.login; + profile.emails = [{ value: json.email }]; + + profile._raw = body; + profile._json = json; + + done(null, profile); + } catch (e) { + done(e); + } + }); +}; + +module.exports = Strategy; \ No newline at end of file