498ca03c11281df79f089b1ae15f998f521845cf
[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 var jwtExchange = function (client, header, claimSet, signature, done) {
278   "use strict";
279
280   var data = header + "." + claimSet,
281       pub = client.get("clientX509PubCert"),
282       verifier = X.crypto.createVerify("RSA-SHA256");
283
284   verifier.update(data);
285
286   if (verifier.verify(pub, utils.base64urlUnescape(signature), 'base64')) {
287     var accessToken = utils.generateUUID(),
288         accesshash,
289         decodedHeader = JSON.parse(utils.base64urlDecode(header)),
290         decodedClaimSet = JSON.parse(utils.base64urlDecode(claimSet)),
291         expDate,
292         initCallback,
293         iatDate,
294         saveOptions = {},
295         today = new Date(),
296         expires = new Date(today.getTime() + (60 * 60 * 1000)), // One hour from now.
297         token = new SYS.Oauth2token();
298
299
300     // Verify JWT was formed correctly.
301     if (!decodedHeader || !decodedHeader.alg || !decodedHeader.typ) {
302       return done(new Error("Invalid JWT header."));
303     }
304
305     if (!decodedClaimSet || decodedClaimSet.length < 5 || !decodedClaimSet.iss ||
306       !decodedClaimSet.scope || !decodedClaimSet.aud || !decodedClaimSet.exp ||
307       !decodedClaimSet.iat) {
308
309       return done(new Error("Invalid JWT claim set."));
310     }
311
312     // These dates will be valid epoch timestamps because of (... * 1000)
313     // don't use for initial check of epoch validity below.
314     expDate = new Date(decodedClaimSet.exp * 1000);
315     iatDate = new Date(decodedClaimSet.iat * 1000);
316
317     if (((new Date(decodedClaimSet.exp)).getTime() <= 0) || ((new Date(decodedClaimSet.iat)).getTime() <= 0) || // exp && iat are NOT valid epoch timestamps.
318       ((expDate.getTime()) - (iatDate.getTime()) <= 0) || // exp - iat <= 0
319       ((expDate.getTime()) - (iatDate.getTime()) > 3600000) || // exp is more than 1 hour from the iat time.
320       (((iatDate - today) - (10 * 60 * 1000)) > 0) // Great Scott! JWT was issued in the future. 10 minute buffer for clock errors.
321       ) {
322
323       return done(new Error("Invalid JWT timestamps."));
324     }
325
326     // Is the JWT ClaimSet.iss a valid clientID?
327     if (client.get("clientID") !== decodedClaimSet.iss) {
328       return done(new Error("Invalid JWT iss."));
329     }
330
331     // JWT is only valid for 1 hour. Has it expired yet?
332     if ((expDate - today) < 0) {
333       return done(new Error("JWT has expired."));
334     }
335
336     // Validate decodedClaimSet.prn user and scopes.
337     if (client.get("delegatedAccess") && decodedClaimSet.prn) {
338       db.users.findByUsername(decodedClaimSet.prn, client.get("organization"), function (err, user) {
339         if (err) { return done(new Error("Invalid JWT delegate user.")); }
340         if (!user) { return done(null, false); }
341
342         var separator = ' ',
343             jwtScopes = decodedClaimSet.scope.split(separator),
344             scope,
345             scopes = [];
346
347         if (!Array.isArray(jwtScopes)) { jwtScopes = [ jwtScopes ]; }
348
349         // Loop through the scope URIs and convert them to org names.
350         _.each(jwtScopes, function (scopeValue, scopeKey, scopeList) {
351           var scopeOrg;
352
353           // Get the org from the scope URI e.g. 'dev' from: 'https://mobile.xtuple.com/auth/dev'
354           scope = url.parse(scopeValue, true);
355           scopeOrg = scope.path.split("/")[1];
356
357           if (user.get("organization") === scopeOrg) {
358             scopes[scopeKey] = scopeOrg;
359           }
360         });
361
362         if (scopes.length < 1) {
363           return done(new Error("Invalid JWT scope."));
364         }
365
366         // JWT is valid, create access token, save and return it.
367
368         // The accessToken is only valid for 1 hour and must be sent with each request to
369         // the REST API. The bcrypt hash calculation on each request would be too expensive.
370         // Therefore, we do not need to bcrypt the accessToken, just SHA1 it.
371         accesshash = X.crypto.createHash('sha1').update(privateSalt + accessToken).digest("hex");
372
373         saveOptions.success = function (model) {
374           if (!model) { return done(null, false); }
375           var params = {};
376
377           params.token_type = model.get("tokenType");
378           // Google sends time until expires instead of just the time it expires at, so...
379           params.expires_in = Math.round(((expires - today) / 1000) - 60); // Seconds until the token expires with 60 sec padding.
380
381           // Send the accessToken and params along.
382           // We do not send the refreshToken because they already have it.
383           return done(null, accessToken, params);
384         };
385         saveOptions.error = function (model, err) {
386           return done && done(err);
387         };
388
389         saveOptions.database = scopes[0];
390
391         initCallback = function (model, value) {
392           if (model.id) {
393             // Now that model is ready, set attributes and save.
394             var tokenAttributes = {
395               clientID: client.get("clientID"),
396               scope: JSON.stringify(scopes),
397               state: "JWT Access Token Issued",
398               approvalPrompt: false,
399               accessToken: accesshash,
400               accessIssued: today,
401               accessExpires: expires,
402               tokenType: "bearer",
403               accessType: "offline",
404               delegate: user.get("username")
405             };
406
407             // Try to save access token data to the database.
408             model.save(tokenAttributes, saveOptions);
409           } else {
410             return done && done(new Error('Cannot save access token. No id set.'));
411           }
412         };
413
414         // Register on change of id callback to know when the model is initialized.
415         token.on('change:id', initCallback);
416
417         // Set model values and save.
418         token.initialize(null, {isNew: true, database: scopes[0]});
419       });
420     } else {
421       // Either there is no prn, OR client.delegatedAccess is not enabled.
422       // TODO: Right now, if you create a service account and uncheck the "delegatedAccess"
423       //   field, then you will see this error. We need to handle public scopes with no
424       //   delegated users here.
425       return done(new Error("Invalid JWT. No delegated user or delegated access is not enabled for this client."));
426     }
427   } else {
428     return done(new Error("Invalid JWT. Signature verification failed"));
429   }
430 };
431 // Support both known grant types.
432 //server.exchange('assertion', jwtBearer(jwtExchange));
433 server.exchange('urn:ietf:params:oauth:grant-type:jwt-bearer', jwtBearer(jwtExchange));
434
435 // TODO - We need a token revoke endpoint some day.
436 //https://developers.google.com/accounts/docs/OAuth2WebServer#tokenrevoke
437
438
439 // user authorization endpoint
440 //
441 // `authorization` middleware accepts a `validate` callback which is
442 // responsible for validating the client making the authorization request.  In
443 // doing so, is recommended that the `redirectURI` be checked against a
444 // registered value, although security requirements may vary accross
445 // implementations.  Once validated, the `done` callback must be invoked with
446 // a `client` instance, as well as the `redirectURI` to which the user will be
447 // redirected after an authorization decision is obtained.
448 //
449 // This middleware simply initializes a new authorization transaction.  It is
450 // the application's responsibility to authenticate the user and render a dialog
451 // to obtain their approval (displaying details about the client requesting
452 // authorization).  We accomplish that here by routing through `ensureLoggedIn()`
453 // first, and rendering the `dialog` view.
454
455 exports.authorization = [
456   server.authorization(function (clientID, redirectURI, scope, type, done) {
457     "use strict";
458
459     // Get the org from the scope URI e.g. 'dev' from: 'https://mobile.xtuple.com/auth/dev'
460     scope = url.parse(scope[0], true);
461     var scopeOrg = scope.path.split("/")[1] || null;
462
463     db.clients.findByClientId(clientID, scopeOrg, function (err, client) {
464       if (err) { return done(err); }
465       if (!client) { return done(null, false); }
466
467       var matches = false;
468
469       // For security purposes, we check that redirectURI provided
470       // by the client matches one registered with the server.
471       _.each(client.get("redirectURIs"), function (value, key, list) {
472 // TODO - When adding the UI interface to allow redirectURI to be saved to the DB,
473 // we need to check and make sure they are https URIs.
474
475         // Check if the requested redirectURI is in approved client.redirectURIs.
476         if (value.redirectURI && value.redirectURI === redirectURI) {
477           matches = true;
478         }
479       });
480
481       if (matches) {
482         return done(null, client, redirectURI);
483       } else {
484         return done(null, false);
485       }
486     });
487   }),
488   function (req, res, next) {
489     "use strict";
490
491     // Load the OAuth req data into the session so it can access it on login redirects.
492     if (req.oauth2) {
493       req.session.oauth2 = req.oauth2;
494       next();
495     }
496
497     // TODO - Client should be able to get a token for a userinfo REST call but
498     // not have a selected org. login.ensureLoggedIn() needs to support this.
499     // This would allow a client not to specify a scope, receive an error that includes
500     // the URI to call to get a user's scope/org list: 'https://mobile.xtuple.com/auth/userinfo.xxx'
501   },
502   login.ensureLoggedIn({redirectTo: "/"}),
503   function (req, res, next) {
504     "use strict";
505
506     var callback,
507         payload = {},
508         rootUrl = req.protocol + "://" + req.host + "/",
509         routes = require('../routes/routes');
510
511     // Handle the returned scopes list.
512     callback = function (result) {
513       if (result.isError) {
514         return next(new Error("Invalid Request."));
515       }
516
517       var client,
518         scope,
519         scopes = [];
520
521       client = {
522         "logo": req.oauth2.client.get("clientLogo"),
523         "name": req.oauth2.client.get("clientName")
524       };
525       scope = req.session.passport.user.organization;
526
527       // Loop through the requested OAuth 2.0 scopes and get the descriptions.
528       for (var i = 0; i < req.oauth2.req.scope.length; i++) {
529         if (result.data && result.data.oauth2 && result.data.oauth2.scopes &&
530           result.data.oauth2.scopes[req.oauth2.req.scope[i]]
531           ) {
532
533           // Add the description for this scope to the array to be displayed by dialog.ejs form.
534           scopes.push(result.data.oauth2.scopes[req.oauth2.req.scope[i]].description);
535         }
536       }
537
538       // Render the dialog.ejs form.
539       res.render('dialog', { transactionID: req.oauth2.transactionID, user: req.user.id, client: client, scope: scope, scopes: scopes });
540     };
541
542
543     if (req.session && req.session.passport && req.session.passport.user && req.session.passport.user.organization) {
544       payload.nameSpace = "XT";
545       payload.type = "Discovery";
546       payload.dispatch = {
547         functionName: "getAuth",
548         parameters: [null, rootUrl]
549       };
550
551       // Get the scopes list from the Discovery Doc.
552       routes.queryDatabase("post", payload, req.session, callback);
553     } else {
554       next(new Error('Invalid OAuth 2.0 scope.'));
555     }
556   }
557 ];
558
559
560 // user decision endpoint
561 //
562 // `decision` middleware processes a user's decision to allow or deny access
563 // requested by a client application.  Based on the grant type requested by the
564 // client, the above grant middleware configured above will be invoked to send
565 // a response.
566
567 exports.decision = [
568   login.ensureLoggedIn({redirectTo: "/"}),
569   server.decision(function (req, next) {
570     "use strict";
571
572     // Add the approved scope/org to req.oauth2.res.
573     var ares = {};
574
575     if (req.session && req.session.passport && req.session.passport.user && req.session.passport.user.organization) {
576       ares.scope = req.session.passport.user.organization;
577
578       // Oauth 2.0 has been approved. Remove it from the session so the user
579       // can login to the app normally again.
580       delete req.session.oauth2;
581
582       return next(null, ares);
583     } else {
584       return next(new Error('Invalid OAuth 2.0 scope.'));
585     }
586   })
587 ];
588
589
590 // token endpoint
591 //
592 // `token` middleware handles client requests to exchange authorization grants
593 // for access tokens.  Based on the grant type being exchanged, the above
594 // exchange middleware will be invoked to handle the request.  Clients must
595 // authenticate when making requests to this endpoint.
596
597 exports.token = [
598   passport.authenticate(['basic', 'oauth2-client-password', 'oauth2-jwt-bearer'], { session: false }),
599   server.token(),
600   server.errorHandler()
601 ];