1 | var EventEmitter = require('events').EventEmitter;
|
2 | var _ = require('lodash');
|
3 | var http = require('request');
|
4 | var RSVP = require('rsvp');
|
5 | var jwt = require('jwt-simple');
|
6 | var urls = require('url');
|
7 | var util = require('util');
|
8 |
|
9 | function HipChat(addon, app){
|
10 | var self = this;
|
11 |
|
12 |
|
13 | addon.middleware = self.middleware;
|
14 | addon.loadClientInfo = self.loadClientInfo;
|
15 | addon.authenticate = self.authenticate;
|
16 | addon._configure = self._configure;
|
17 | addon.getAccessToken = self.getAccessToken;
|
18 |
|
19 |
|
20 | addon.register = function(){
|
21 | self.logger.info('Auto registration not available with HipChat add-ons')
|
22 | };
|
23 |
|
24 | addon._verifyKeys = function(){};
|
25 |
|
26 |
|
27 | _.extend(self, addon);
|
28 | }
|
29 |
|
30 | var proto = HipChat.prototype = Object.create(EventEmitter.prototype);
|
31 |
|
32 | proto.getAccessToken = function(clientInfo, scopes) {
|
33 | var self = this;
|
34 | function generateAccessToken(scopes){
|
35 | return new RSVP.Promise(function(resolve, reject){
|
36 | var tokenUrl = clientInfo.capabilitiesDoc.capabilities.oauth2Provider.tokenUrl;
|
37 | http.post(tokenUrl, {
|
38 | form: {
|
39 | 'grant_type': 'client_credentials',
|
40 | 'scope': scopes.join(' ')
|
41 | },
|
42 | auth: {
|
43 | user: clientInfo.clientKey,
|
44 | pass: clientInfo.oauthSecret
|
45 | }
|
46 | }, function(err, res, body){
|
47 | if(!err) {
|
48 | try {
|
49 | var token = JSON.parse(body);
|
50 | token.created = new Date().getTime() / 1000;
|
51 | resolve(token);
|
52 | } catch(e) {
|
53 | reject(e);
|
54 | }
|
55 | } else {
|
56 | reject(err);
|
57 | }
|
58 | });
|
59 | });
|
60 | }
|
61 |
|
62 | return new RSVP.Promise(function(resolve, reject){
|
63 | scopes = scopes || self.descriptor.capabilities.hipchatApiConsumer.scopes;
|
64 | var scopeKey = scopes.join("|");
|
65 |
|
66 | function generate() {
|
67 | generateAccessToken(scopes).then(
|
68 | function(token) {
|
69 | self.settings.set(scopeKey, token, clientInfo.clientKey);
|
70 | resolve(token);
|
71 | },
|
72 | function(err) {
|
73 | reject(err);
|
74 | }
|
75 | );
|
76 | }
|
77 |
|
78 | self.settings.get(scopeKey, clientInfo.clientKey).then(function(token){
|
79 | if (token) {
|
80 | if (token.expires_in + token.created < (new Date().getTime() / 1000)) {
|
81 | generate();
|
82 | } else {
|
83 | resolve(token);
|
84 | }
|
85 | } else {
|
86 | generate();
|
87 | }
|
88 | }, function(err) {
|
89 | reject(err);
|
90 | });
|
91 | });
|
92 | };
|
93 |
|
94 | proto._configure = function(){
|
95 | var self = this;
|
96 | var baseUrl = urls.parse(self.config.localBaseUrl());
|
97 | var basePath = baseUrl.path && baseUrl.path.length > 1 ? baseUrl.path : '';
|
98 |
|
99 | self.app.get(basePath + '/atlassian-connect.json', function (req, res) {
|
100 | res.json(self.descriptor);
|
101 | });
|
102 |
|
103 |
|
104 | function verifyInstallation(url){
|
105 | return new RSVP.Promise(function(resolve, reject){
|
106 | http.get(url, function(err, res, body){
|
107 | var data = JSON.parse(body);
|
108 | if(!err){
|
109 | if(data.links.self === url){
|
110 | resolve(data);
|
111 | } else {
|
112 | reject("The capabilities URL " + url + " doesn't match the resource's self link " + data.links.self);
|
113 | }
|
114 | } else {
|
115 | reject(err);
|
116 | }
|
117 | });
|
118 | });
|
119 | };
|
120 |
|
121 |
|
122 | if (typeof self.descriptor.capabilities.installable != 'undefined') {
|
123 | var callbackUrl = '/'+self.descriptor.capabilities.installable.callbackUrl.split('/').slice(3).join('/');
|
124 |
|
125 |
|
126 | self.app.post(
|
127 |
|
128 |
|
129 | callbackUrl,
|
130 |
|
131 |
|
132 |
|
133 |
|
134 | function (req, res) {
|
135 | try {
|
136 | verifyInstallation(req.body.capabilitiesUrl)
|
137 | .then(function(hcCapabilities){
|
138 | var clientInfo = {
|
139 | clientKey: req.body.oauthId,
|
140 | oauthSecret: req.body.oauthSecret,
|
141 | capabilitiesUrl: req.body.capabilitiesUrl,
|
142 | capabilitiesDoc: hcCapabilities,
|
143 | roomId: req.body.roomId
|
144 | };
|
145 | var clientKey = clientInfo.clientKey;
|
146 | self.getAccessToken(clientInfo)
|
147 | .then(function(tokenObj){
|
148 | clientInfo.groupId = tokenObj.group_id;
|
149 | clientInfo.groupName = tokenObj.group_name;
|
150 | self.emit('installed', clientKey, clientInfo, req);
|
151 | self.emit('plugin_enabled', clientKey, clientInfo, req);
|
152 | self.settings.set('clientInfo', clientInfo, clientKey).then(function (data) {
|
153 | self.logger.info("Saved tenant details for " + clientKey + " to database\n" + util.inspect(data));
|
154 | self.emit('host_settings_saved', clientKey, data);
|
155 | res.send(204);
|
156 | }, function (err) {
|
157 | res.send(500, 'Could not lookup stored client data for ' + clientKey + ': ' + err);
|
158 | });
|
159 | })
|
160 | .then(null, function(err){
|
161 | res.send(500, err);
|
162 | });
|
163 | })
|
164 | .then(null, function(err){
|
165 | res.send(500, err);
|
166 | }
|
167 | );
|
168 | } catch (e) {
|
169 | res.send(500, e);
|
170 | }
|
171 | }
|
172 | );
|
173 | }
|
174 |
|
175 |
|
176 | self.app.delete(
|
177 | callbackUrl + '/:oauthId',
|
178 |
|
179 | function(req, res){
|
180 | try {
|
181 | self.emit('uninstalled', req.params.oauthId);
|
182 | res.send(204);
|
183 | } catch (e) {
|
184 | res.send(500, e);
|
185 | }
|
186 | }
|
187 | );
|
188 | }
|
189 |
|
190 | proto.middleware = function(){
|
191 |
|
192 | var addon = this;
|
193 | return function(req, res, next){
|
194 | var hostUrl = req.param('xdmhost');
|
195 | var params;
|
196 |
|
197 | if (hostUrl) {
|
198 | params = {
|
199 | hostBaseUrl: hostUrl
|
200 | };
|
201 | _.extend(req.session, params);
|
202 | } else {
|
203 | params = req.session;
|
204 | }
|
205 |
|
206 | augmentRequest(params, req, res, next);
|
207 |
|
208 | }
|
209 |
|
210 | function augmentRequest(params, req, res, next) {
|
211 | if (params && params.hostBaseUrl) {
|
212 | res.locals = _.extend({}, res.locals || {}, params, {
|
213 | title: addon.config.name,
|
214 | appKey: addon.config.key,
|
215 | localBaseUrl: addon.config.localBaseUrl(),
|
216 | hostStylesheetUrl: hostResourceUrl(addon.app, params.hostBaseUrl, 'css'),
|
217 | hostScriptUrl: hostResourceUrl(addon.app, params.hostBaseUrl, 'js')
|
218 | });
|
219 | }
|
220 |
|
221 | next();
|
222 | }
|
223 |
|
224 | function hostResourceUrl(app, baseUrl, type) {
|
225 | var suffix = app.get('env') === 'development' ? '-debug' : '';
|
226 | return baseUrl + '/atlassian-connect/all' + suffix + '.' + type;
|
227 | }
|
228 | }
|
229 |
|
230 | proto.loadClientInfo = function(clientKey) {
|
231 | var self = this;
|
232 | return new RSVP.Promise(function(resolve, reject){
|
233 | self.settings.get('clientInfo', clientKey).then(function(d){
|
234 | resolve(d);
|
235 | }, function(err) {
|
236 | reject(err);
|
237 | });
|
238 | });
|
239 | };
|
240 |
|
241 |
|
242 | proto.authenticate = function(){
|
243 | var self = this;
|
244 |
|
245 | return function(req, res, next){
|
246 | function send(code, msg) {
|
247 | self.logger.error('JWT verification error:', code, msg);
|
248 | res.send(code, msg);
|
249 | }
|
250 |
|
251 | function success(jwtToken, clientInfo) {
|
252 | if (jwtToken && jwtToken.iss && req.session) {
|
253 | req.session.clientKey = jwtToken.iss;
|
254 | }
|
255 |
|
256 |
|
257 | var now = Math.floor(Date.now()/1000);
|
258 | jwtToken.iat = now
|
259 |
|
260 | jwtToken.exp = now + (self.config.maxTokenAge() / 1000);
|
261 | res.locals.signed_request = jwt.encode(jwtToken, clientInfo.oauthSecret);
|
262 | res.set('x-acpt', res.locals.signed_request);
|
263 |
|
264 | req.context = jwtToken.context;
|
265 | req.clientInfo = clientInfo;
|
266 | next();
|
267 | }
|
268 |
|
269 | var signedRequest = req.query.signed_request || req.headers['x-acpt'];
|
270 | if (signedRequest) {
|
271 | try {
|
272 |
|
273 | var unverifiedClaims = jwt.decode(signedRequest, null, true);
|
274 |
|
275 | var issuer = unverifiedClaims.iss;
|
276 | if (!issuer) {
|
277 | send('JWT claim did not contain the issuer (iss) claim');
|
278 | return;
|
279 | }
|
280 |
|
281 |
|
282 | self.loadClientInfo(issuer).then(function(clientInfo){
|
283 |
|
284 | if (clientInfo === null) {
|
285 | return send(400, "Request can't be verified without an OAuth secret");
|
286 | }
|
287 | var verifiedClaims = jwt.decode(signedRequest, clientInfo.oauthSecret);
|
288 |
|
289 |
|
290 |
|
291 | var now = Math.floor(Date.now()/1000);;
|
292 | if (self.config.maxTokenAge()) {
|
293 | var issuedAt = verifiedClaims.iat;
|
294 | var expiresInSecs = self.config.maxTokenAge() / 1000;
|
295 | if(issuedAt && now >= (issuedAt + expiresInSecs)){
|
296 | send(401, 'Authentication request has expired.');
|
297 | return;
|
298 | }
|
299 |
|
300 | } else {
|
301 | var expiry = verifiedClaims.exp;
|
302 | if (expiry && now >= expiry) {
|
303 | send(401, 'Authentication request has expired.');
|
304 | return;
|
305 | }
|
306 | }
|
307 |
|
308 | success(verifiedClaims, clientInfo);
|
309 | }, function(err) {
|
310 | return send(400, err.message);
|
311 | });
|
312 | } catch(e){
|
313 | return send(400, e.message);
|
314 | }
|
315 | } else if (req.body.oauth_client_id) {
|
316 | self.settings.get('clientInfo', req.body.oauth_client_id).then(function(d){
|
317 | try {
|
318 | req.clientInfo = d;
|
319 | req.context = req.body;
|
320 | next();
|
321 | } catch(e){
|
322 | return send(400, e.message);
|
323 | }
|
324 | });
|
325 | } else {
|
326 | return send(400, "Request not signed and therefore can't be verified");
|
327 | }
|
328 | }
|
329 | }
|
330 |
|
331 | module.exports = function(addon, app){
|
332 | return new HipChat(addon, app);
|
333 | }
|