Merge pull request #1609 from xtuple/4_5_x
[xtuple] / node-datasource / oauth2 / oauth2.js
1 /*jshint node:true, indent:2, curly:false, eqeqeq:true, immed:true, latedef:true, newcap:true, noarg:true,
2 regexp:true, undef:true, strict:true, trailing:true, white:true, expr:true */
3 /*global X:true, SYS:true, _:true, console:true*/
4
5 /**
6  * Module dependencies.
7  */
8 var auth = require('../routes/auth'),
9     oauth2orize = require('oauth2orize'),
10     jwtBearer = require('oauth2orize-jwt-bearer').Exchange,
11     passport = require('passport'),
12     login = require('connect-ensure-login'),
13     db = require('./db'),
14     url = require('url'),
15     utils = require('./utils'),
16     privateSalt = X.fs.readFileSync(X.options.datasource.saltFile).toString();
17
18 // create OAuth 2.0 server
19 var server = oauth2orize.createServer();
20
21 // Register serialialization and deserialization functions.
22 //
23 // When a client redirects a user to user authorization endpoint, an
24 // authorization transaction is initiated.  To complete the transaction, the
25 // user must authenticate and approve the authorization request.  Because this
26 // may involve multiple HTTP request/response exchanges, the transaction is
27 // stored in the session.
28 //
29 // An application must supply serialization functions, which determine how the
30 // client object is serialized into the session.  Typically this will be a
31 // simple matter of serializing the client's ID, and deserializing by finding
32 // the client by ID from the database.
33
34 server.serializeClient(function (client, done) {
35   "use strict";
36
37   return done(null, client);
38 });
39
40 server.deserializeClient(function (client, done) {
41   "use strict";
42
43   db.clients.find(client, function (err, foundClient) {
44     if (err) { return done(err); }
45     return done(null, foundClient);
46   });
47 });
48
49 // Register supported grant types.
50 //
51 // OAuth 2.0 specifies a framework that allows users to grant client
52 // applications limited access to their protected resources.  It does this
53 // through a process of the user granting access, and the client exchanging
54 // the grant for an access token.
55
56 // Grant authorization codes.  The callback takes the `client` requesting
57 // authorization, the `redirectURI` (which is used as a verifier in the
58 // subsequent exchange), the authenticated `user` granting access, and
59 // their response, which contains approved scope, duration, etc. as parsed by
60 // the application.  The application issues a code, which is bound to these
61 // values, and will be exchanged for an access token.
62
63 server.grant(oauth2orize.grant.code(function (client, redirectURI, user, ares, done) {
64   "use strict";
65
66   if (!client || !user || !redirectURI || !ares) { return done(null, false); }
67
68   // Generate the auth code.
69   var code = utils.generateUUID(),
70       salt = '$2a$10$' + client.get("clientID").replace(/[^a-zA-Z0-9]/g, "").substring(0, 22),
71       codehash = X.bcrypt.hashSync(code, salt);
72
73   // The authCode can be used to get a refreshToken and accessToken. We bcrypt the authCode
74   // so if our database is ever compromised, the stored authCode hashes are worthless.
75
76   if (!Array.isArray(ares.scope)) { ares.scope = [ ares.scope ]; }
77
78   // Save auth data to the database.
79   db.authorizationCodes.save(codehash, client.get("clientID"), redirectURI, user.id, ares.scope, function (err) {
80     if (err) {
81       return done(err);
82     }
83
84     // Return the code to the client.
85     done(null, code);
86   });
87 }));
88
89 // Exchange authorization codes for access tokens.  The callback accepts the
90 // `client`, which is exchanging `code` and any `redirectURI` from the
91 // authorization request for verification.  If these values are validated, the
92 // application issues an access token on behalf of the user who authorized the
93 // code.
94
95 server.exchange(oauth2orize.exchange.code(function (client, code, redirectURI, done) {
96   "use strict";
97
98   if (!client || !code || !redirectURI) { return done(null, false); }
99
100   // Best practice is to use a random salt in each bcrypt hash. Since we need to query the
101   // database for a valid authCode, we would have to loop through all the hashes
102   // and hash the authCode the client sent using each salt and check for a match.
103   // That could take a lot of CPU if there are 1000's of authCodes. Instead, we will
104   // use known salt we can look up that is also in the request to exchange authCodes.
105   // The salt is the client_id trimmed to 22 characters. Unfortunately, this trade off means
106   // the bcrypt salt will be shared across all authCodes issued for a single client.
107
108   if (client.get("clientID").length < 22) {
109     console.trace("OAuth 2.0 clientID, ", client.get("clientID"), " is too short to use for bcrypt salt.");
110     return done(new Error("Invalid authorization code."));
111   }
112
113   // bcrypt the code before looking for a matching hash.
114   var salt = '$2a$10$' + client.get("clientID").replace(/[^a-zA-Z0-9]/g, "").substring(0, 22),
115       codehash = X.bcrypt.hashSync(code, salt);
116
117   db.authorizationCodes.find(codehash, client.get("organization"), function (err, authCode) {
118     if (err) { return done(err); }
119     if (!authCode) { return done(null, false); }
120     if (client.get("clientID") !== authCode.get("clientID")) { return done(new Error("Invalid clientID.")); }
121     if (redirectURI !== authCode.get("redirectURI")) { return done(new Error("Invalid redirectURI.")); }
122
123     // Now that we've looked up the bcrypt authCode hash, double check that the code
124     // sent by the client actually matches using compareSync() this time.
125     if (!X.bcrypt.compareSync(code, authCode.get("authCode"))) {
126       console.trace("OAuth 2.0 authCode failed bcrypt compare. WTF?? This should not happen.");
127       return done(new Error("Invalid authorization code."));
128     }
129
130     // Auth code is only valid for 10 minutes. Has it expired yet?
131     if ((new Date(authCode.get("authCodeExpires")) - new Date()) < 0) {
132       authCode.destroy();
133       return done(new Error("Authorization code has expired."));
134     }
135
136     var accessToken = utils.generateUUID(),
137         refreshToken = utils.generateUUID(),
138         accesshash,
139         refreshhash,
140         saveOptions = {},
141         today = new Date(),
142         expires = new Date(today.getTime() + (60 * 60 * 1000)), // One hour from now.
143         tokenType = 'bearer';
144
145     // A refreshToken is like a password. It currently never expires and with it, you can
146     // get a new accessToken. We bcrypt the refreshToken so if our database is ever
147     // compromised, the stored refreshToken hashes are worthless.
148     refreshhash = X.bcrypt.hashSync(refreshToken, salt);
149
150     // The accessToken is only valid for 1 hour and must be sent with each request to
151     // the REST API. The bcrypt hash calculation on each request would be too expensive.
152     // Therefore, we do not need to bcrypt the accessToken, just SHA1 it.
153     accesshash = X.crypto.createHash('sha1').update(privateSalt + accessToken).digest("hex");
154
155     saveOptions.success = function (model) {
156       if (!model) { return done(null, false); }
157       var params = {};
158
159       params.token_type = model.get("tokenType");
160       // Google sends time until expires instead of just the time it expires at, so...
161       params.expires_in = Math.round(((expires - today) / 1000) - 60); // Seconds until the token expires with 60 sec padding.
162
163       // Send the tokens and params along.
164       return done(null, accessToken, refreshToken, params);
165     };
166     saveOptions.error = function (model, err) {
167       return done && done(err);
168     };
169     saveOptions.database = client.get("organization");
170
171     // Set model values and save.
172     authCode.set("state", "Token Issued");
173     authCode.set("authCode", null);
174     authCode.set("authCodeExpires", today);
175     authCode.set("refreshToken", refreshhash);
176     authCode.set("refreshIssued", today);
177     authCode.set("accessToken", accesshash);
178     authCode.set("accessIssued", today);
179     authCode.set("accessExpires", expires);
180     authCode.set("tokenType", tokenType);
181     authCode.set("accessType", "offline"); // Default for now...
182
183     authCode.save(null, saveOptions);
184   });
185 }));
186
187 // Exchange a refresh token for a new access tokens. The callback accepts the
188 // `Oauth2client` model, `Oauth2token` model and a done callback. If these
189 // values are valid, the application issues an access token on behalf of the
190 // user who authorized the code.
191
192 server.exchange(oauth2orize.exchange.refreshToken(function (client, refreshToken, done) {
193   "use strict";
194
195   if (!client || !refreshToken) { return done(null, false); }
196
197   // Best practice is to use a random salt in each bcrypt hash. Since we need to query the
198   // database for a valid refreshToken, we would have to loop through all the hashes
199   // and hash the refreshToken the client sent using each salt and check for a match.
200   // That could take a lot of CPU if there are 1000's of refreshTokens. Instead, we will
201   // use known salt we can look up that is also in the request to use refreshTokens.
202   // The salt is the client_id trimmed to 22 characters. Unfortunately, this trade off means
203   // the bcrypt salt will be shared across all refreshTokens issued for a single client.
204
205   if (client.get("clientID").length < 22) {
206     console.trace("OAuth 2.0 clientID, ", client.get("clientID"), " is too short to use for bcrypt salt.");
207     return done(new Error("Invalid refresh token."));
208   }
209
210   // bcrypt the refreshToken before looking for a matching hash.
211   var salt = '$2a$10$' + client.get("clientID").replace(/[^a-zA-Z0-9]/g, "").substring(0, 22),
212       refreshhash = X.bcrypt.hashSync(refreshToken, salt);
213
214   db.accessTokens.findByRefreshToken(refreshhash, client.get("organization"), function (err, token) {
215     if (err) { return done(err); }
216     if (!token) { return done(null, false); }
217     if (client.get("clientID") !== token.get("clientID")) { return done(new Error("Invalid clientID.")); }
218
219     // Now that we've looked up the bcrypt refreshToken hash, double check that the code
220     // sent by the client actually matches using compareSync() this time.
221     if (!X.bcrypt.compareSync(refreshToken, token.get("refreshToken"))) {
222       console.trace("OAuth 2.0 refreshToken failed bcrypt compare. WTF?? This should not happen.");
223       return done(new Error("Invalid refresh token."));
224     }
225
226     // Refresh tokens do not currently expire, but we might add that feature in the future. Has it expired yet?
227     // TODO - refreshExpires === null means refreshToken doesn't expire. If we change that, determine how to handle null.
228     if (token.get("refreshExpires") && ((new Date(token.get("refreshExpires")) - new Date()) < 0)) {
229       token.destroy();
230       return done(new Error("Refresh token has expired."));
231     }
232
233     var accessToken = utils.generateUUID(),
234         accesshash,
235         saveOptions = {},
236         today = new Date(),
237         expires = new Date(today.getTime() + (60 * 60 * 1000)); // One hour from now.
238
239     // The accessToken is only valid for 1 hour and must be sent with each request to
240     // the REST API. The bcrypt hash calculation on each request would be too expensive.
241     // Therefore, we do not need to bcrypt the accessToken, just SHA1 it.
242     accesshash = X.crypto.createHash('sha1').update(privateSalt + accessToken).digest("hex");
243
244     saveOptions.success = function (model) {
245       if (!model) { return done(null, false); }
246       var params = {};
247
248       params.token_type = model.get("tokenType");
249       // Google sends time until expires instead of just the time it expires at, so...
250       params.expires_in = Math.round(((expires - today) / 1000) - 60); // Seconds until the token expires with 60 sec padding.
251
252       // Send the accessToken and params along.
253       // We do not send the refreshToken because they already have it.
254       return done(null, accessToken, null, params);
255     };
256     saveOptions.error = function (model, err) {
257       return done && done(err);
258     };
259
260     saveOptions.database = client.get("organization");
261
262     // Set model values and save.
263     token.set("state", "Token Refreshed");
264     token.set("accessToken", accesshash);
265     token.set("accessIssued", today);
266     token.set("accessExpires", expires);
267
268     token.save(null, saveOptions);
269   });
270 }));
271
272 // Exchange a JSON Web Token (JWT) for a new access tokens. The callback accepts
273 // the `Oauth2client` model, the JWT base64URLencoded header, claimSet and
274 // signature parts and a done callback. If these values are valid, the
275 // application issues an access token on behalf of the user in the JWT `prn`
276 // property.
277
278 server.exchange('assertion', jwtBearer(function (client, header, claimSet, signature, done) {
279   "use strict";
280
281   var data = header + "." + claimSet,
282       pub = client.get("clientX509PubCert"),
283       verifier = X.crypto.createVerify("RSA-SHA256");
284
285   verifier.update(data);
286
287   if (verifier.verify(pub, utils.base64urlUnescape(signature), 'base64')) {
288     var accessToken = utils.generateUUID(),
289         accesshash,
290         decodedHeader = JSON.parse(utils.base64urlDecode(header)),
291         decodedClaimSet = JSON.parse(utils.base64urlDecode(claimSet)),
292         expDate,
293         initCallback,
294         iatDate,
295         saveOptions = {},
296         today = new Date(),
297         expires = new Date(today.getTime() + (60 * 60 * 1000)), // One hour from now.
298         token = new SYS.Oauth2token();
299
300     // Verify JWT was formed correctly.
301     if (!decodedHeader || !decodedHeader.alg || !decodedHeader.typ) {
302       return done(new Error("Invalid JWT header."));
303     }
304     if (!decodedClaimSet || decodedClaimSet.length < 5 || !decodedClaimSet.iss ||
305       !decodedClaimSet.scope || !decodedClaimSet.aud || !decodedClaimSet.exp ||
306       !decodedClaimSet.iat) {
307
308       return done(new Error("Invalid JWT claim set."));
309     }
310
311     // These dates will be valid epoch timestamps because of (... * 1000)
312     // don't use for initial check of epoch validity below.
313     expDate = new Date(decodedClaimSet.exp * 1000);
314     iatDate = new Date(decodedClaimSet.iat * 1000);
315
316     if (((new Date(decodedClaimSet.exp)).getTime() <= 0) || ((new Date(decodedClaimSet.iat)).getTime() <= 0) || // exp && iat are NOT valid epoch timestamps.
317       ((expDate.getTime()) - (iatDate.getTime()) <= 0) || // exp - iat <= 0
318       ((expDate.getTime()) - (iatDate.getTime()) > 3600000) || // exp is more than 1 hour from the iat time.
319       (((iatDate - today) - (10 * 60 * 1000)) > 0) // Great Scott! JWT was issued in the future. 10 minute buffer for clock errors.
320       ) {
321
322       return done(new Error("Invalid JWT timestamps."));
323     }
324
325     // Is the JWT ClaimSet.iss a valid clientID?
326     if (client.get("clientID") !== decodedClaimSet.iss) {
327       return done(new Error("Invalid JWT iss."));
328     }
329
330     // JWT is only valid for 1 hour. Has it expired yet?
331     if ((expDate - today) < 0) {
332       return done(new Error("JWT has expired."));
333     }
334
335     // Validate decodedClaimSet.prn user and scopes.
336     if (client.get("delegatedAccess") && decodedClaimSet.prn) {
337       db.users.findByUsername(decodedClaimSet.prn, client.get("organization"), function (err, user) {
338         if (err) { return done(new Error("Invalid JWT delegate user.")); }
339         if (!user) { return done(null, false); }
340
341         var separator = ' ',
342             jwtScopes = decodedClaimSet.scope.split(separator),
343             scope,
344             scopes = [];
345
346         if (!Array.isArray(jwtScopes)) { jwtScopes = [ jwtScopes ]; }
347
348         // Loop through the scope URIs and convert them to org names.
349         _.each(jwtScopes, function (scopeValue, scopeKey, scopeList) {
350           var scopeOrg;
351
352           // Get the org from the scope URI e.g. 'dev' from: 'https://mobile.xtuple.com/auth/dev'
353           scope = url.parse(scopeValue, true);
354           scopeOrg = scope.path.split("/")[1];
355
356           if (user.get("organization") === scopeOrg) {
357             scopes[scopeKey] = scopeOrg;
358           }
359         });
360
361         if (scopes.length < 1) {
362           return done(new Error("Invalid JWT scope."));
363         }
364
365         // JWT is valid, create access token, save and return it.
366
367         // The accessToken is only valid for 1 hour and must be sent with each request to
368         // the REST API. The bcrypt hash calculation on each request would be too expensive.
369         // Therefore, we do not need to bcrypt the accessToken, just SHA1 it.
370         accesshash = X.crypto.createHash('sha1').update(privateSalt + accessToken).digest("hex");
371
372         saveOptions.success = function (model) {
373           if (!model) { return done(null, false); }
374           var params = {};
375
376           params.token_type = model.get("tokenType");
377           // Google sends time until expires instead of just the time it expires at, so...
378           params.expires_in = Math.round(((expires - today) / 1000) - 60); // Seconds until the token expires with 60 sec padding.
379
380           // Send the accessToken and params along.
381           // We do not send the refreshToken because they already have it.
382           return done(null, accessToken, params);
383         };
384         saveOptions.error = function (model, err) {
385           return done && done(err);
386         };
387
388         saveOptions.database = scopes[0];
389
390         initCallback = function (model, value) {
391           if (model.id) {
392             // Now that model is ready, set attributes and save.
393             var tokenAttributes = {
394               clientID: client.get("clientID"),
395               scope: JSON.stringify(scopes),
396               state: "JWT Access Token Issued",
397               approvalPrompt: false,
398               accessToken: accesshash,
399               accessIssued: today,
400               accessExpires: expires,
401               tokenType: "bearer",
402               accessType: "offline",
403               delegate: user.get("username")
404             };
405
406             // Try to save access token data to the database.
407             model.save(tokenAttributes, saveOptions);
408           } else {
409             return done && done(new Error('Cannot save access token. No id set.'));
410           }
411         };
412
413         // Register on change of id callback to know when the model is initialized.
414         token.on('change:id', initCallback);
415
416         // Set model values and save.
417         token.initialize(null, {isNew: true, database: scopes[0]});
418       });
419     } else {
420       // No prn, throw error for now.
421       return done(new Error("Invalid JWT. No delegate user."));
422
423       // TODO - Handle public scopes with no delegatedAccess users if we ever need to.
424     }
425   } else {
426     return done(new Error("Invalid JWT. Signature verification failed"));
427   }
428 }));
429
430
431 // TODO - We need a token revoke endpoint some day.
432 //https://developers.google.com/accounts/docs/OAuth2WebServer#tokenrevoke
433
434
435 // user authorization endpoint
436 //
437 // `authorization` middleware accepts a `validate` callback which is
438 // responsible for validating the client making the authorization request.  In
439 // doing so, is recommended that the `redirectURI` be checked against a
440 // registered value, although security requirements may vary accross
441 // implementations.  Once validated, the `done` callback must be invoked with
442 // a `client` instance, as well as the `redirectURI` to which the user will be
443 // redirected after an authorization decision is obtained.
444 //
445 // This middleware simply initializes a new authorization transaction.  It is
446 // the application's responsibility to authenticate the user and render a dialog
447 // to obtain their approval (displaying details about the client requesting
448 // authorization).  We accomplish that here by routing through `ensureLoggedIn()`
449 // first, and rendering the `dialog` view.
450
451 exports.authorization = [
452   server.authorization(function (clientID, redirectURI, scope, type, done) {
453     "use strict";
454
455     // Get the org from the scope URI e.g. 'dev' from: 'https://mobile.xtuple.com/auth/dev'
456     scope = url.parse(scope[0], true);
457     var scopeOrg = scope.path.split("/")[1] || null;
458
459     db.clients.findByClientId(clientID, scopeOrg, function (err, client) {
460       if (err) { return done(err); }
461       if (!client) { return done(null, false); }
462
463       var matches = false;
464
465       // For security purposes, we check that redirectURI provided
466       // by the client matches one registered with the server.
467       _.each(client.get("redirectURIs"), function (value, key, list) {
468 // TODO - When adding the UI interface to allow redirectURI to be saved to the DB,
469 // we need to check and make sure they are https URIs.
470
471         // Check if the requested redirectURI is in approved client.redirectURIs.
472         if (value.redirectURI && value.redirectURI === redirectURI) {
473           matches = true;
474         }
475       });
476
477       if (matches) {
478         return done(null, client, redirectURI);
479       } else {
480         return done(null, false);
481       }
482     });
483   }),
484   function (req, res, next) {
485     "use strict";
486
487     // Load the OAuth req data into the session so it can access it on login redirects.
488     if (req.oauth2) {
489       req.session.oauth2 = req.oauth2;
490       next();
491     }
492
493     // TODO - Client should be able to get a token for a userinfo REST call but
494     // not have a selected org. login.ensureLoggedIn() needs to support this.
495     // This would allow a client not to specify a scope, receive an error that includes
496     // the URI to call to get a user's scope/org list: 'https://mobile.xtuple.com/auth/userinfo.xxx'
497   },
498   login.ensureLoggedIn({redirectTo: "/"}),
499   function (req, res, next) {
500     "use strict";
501
502     var callback,
503         payload = {},
504         rootUrl = req.protocol + "://" + req.host + "/",
505         routes = require('../routes/routes');
506
507     // Handle the returned scopes list.
508     callback = function (result) {
509       if (result.isError) {
510         return next(new Error("Invalid Request."));
511       }
512
513       var client,
514         scope,
515         scopes = [];
516
517       client = {
518         "logo": req.oauth2.client.get("clientLogo"),
519         "name": req.oauth2.client.get("clientName")
520       };
521       scope = req.session.passport.user.organization;
522
523       // Loop through the requested OAuth 2.0 scopes and get the descriptions.
524       for (var i = 0; i < req.oauth2.req.scope.length; i++) {
525         if (result.data && result.data.oauth2 && result.data.oauth2.scopes &&
526           result.data.oauth2.scopes[req.oauth2.req.scope[i]]
527           ) {
528
529           // Add the description for this scope to the array to be displayed by dialog.ejs form.
530           scopes.push(result.data.oauth2.scopes[req.oauth2.req.scope[i]].description);
531         }
532       }
533
534       // Render the dialog.ejs form.
535       res.render('dialog', { transactionID: req.oauth2.transactionID, user: req.user.id, client: client, scope: scope, scopes: scopes });
536     };
537
538
539     if (req.session && req.session.passport && req.session.passport.user && req.session.passport.user.organization) {
540       payload.nameSpace = "XT";
541       payload.type = "Discovery";
542       payload.dispatch = {
543         functionName: "getAuth",
544         parameters: [null, rootUrl]
545       };
546
547       // Get the scopes list from the Discovery Doc.
548       routes.queryDatabase("post", payload, req.session, callback);
549     } else {
550       next(new Error('Invalid OAuth 2.0 scope.'));
551     }
552   }
553 ];
554
555
556 // user decision endpoint
557 //
558 // `decision` middleware processes a user's decision to allow or deny access
559 // requested by a client application.  Based on the grant type requested by the
560 // client, the above grant middleware configured above will be invoked to send
561 // a response.
562
563 exports.decision = [
564   login.ensureLoggedIn({redirectTo: "/"}),
565   server.decision(function (req, next) {
566     "use strict";
567
568     // Add the approved scope/org to req.oauth2.res.
569     var ares = {};
570
571     if (req.session && req.session.passport && req.session.passport.user && req.session.passport.user.organization) {
572       ares.scope = req.session.passport.user.organization;
573
574       // Oauth 2.0 has been approved. Remove it from the session so the user
575       // can login to the app normally again.
576       delete req.session.oauth2;
577
578       return next(null, ares);
579     } else {
580       return next(new Error('Invalid OAuth 2.0 scope.'));
581     }
582   })
583 ];
584
585
586 // token endpoint
587 //
588 // `token` middleware handles client requests to exchange authorization grants
589 // for access tokens.  Based on the grant type being exchanged, the above
590 // exchange middleware will be invoked to handle the request.  Clients must
591 // authenticate when making requests to this endpoint.
592
593 exports.token = [
594   passport.authenticate(['basic', 'oauth2-client-password', 'oauth2-jwt-bearer'], { session: false }),
595   server.token(),
596   server.errorHandler()
597 ];