separated the code into different files
This commit is contained in:
		
							parent
							
								
									e49299670e
								
							
						
					
					
						commit
						7fa2fdfc11
					
				
							
								
								
									
										355
									
								
								accounts.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										355
									
								
								accounts.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,355 @@ | |||||||
|  | 'use strict'; | ||||||
|  | 
 | ||||||
|  | var crypto = require('crypo'); | ||||||
|  | var PromiseA = require('bluebird'); | ||||||
|  | var OpErr = PromiseA.OperationalError; | ||||||
|  | 
 | ||||||
|  | function makeB64UrlSafe(b64) { | ||||||
|  |   return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/, ''); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function retrieveOtp(codeStore, codeId) { | ||||||
|  |   return codeStore.get(codeId).then(function (code) { | ||||||
|  |     if (!code) { | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var expires = (new Date(code.expires)).valueOf(); | ||||||
|  |     if (!expires || Date.now() > expires) { | ||||||
|  |       return codeStore.destroy(codeId).then(function () { | ||||||
|  |         return null; | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return code; | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | function validateOtp(codeStore, codeId, token) { | ||||||
|  |   if (!codeId) { | ||||||
|  |     return PromiseA.reject(new Error("Must provide authcode ID")); | ||||||
|  |   } | ||||||
|  |   if (!token) { | ||||||
|  |     return PromiseA.reject(new Error("Must provide authcode code")); | ||||||
|  |   } | ||||||
|  |   return codeStore.get(codeId).then(function (code) { | ||||||
|  |     if (!code) { | ||||||
|  |       throw new OpErr('authcode specified does not exist or has expired'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return PromiseA.resolve().then(function () { | ||||||
|  |       var attemptsLeft = 3 - (code.attempts && code.attempts.length || 0); | ||||||
|  |       if (attemptsLeft <= 0) { | ||||||
|  |         throw new OpErr('you have tried to authorize this code too many times'); | ||||||
|  |       } | ||||||
|  |       if (code.code !== token) { | ||||||
|  |         throw new OpErr('you have entered the code incorrectly. '+attemptsLeft+' attempts remaining'); | ||||||
|  |       } | ||||||
|  |       // TODO: maybe impose a rate limit, although going fast doesn't help you break the
 | ||||||
|  |       // system when you can only try 3 times total.
 | ||||||
|  |     }).then(function () { | ||||||
|  |       return codeStore.destroy(codeId).then(function () { | ||||||
|  |         return code; | ||||||
|  |       }); | ||||||
|  |     }, function (err) { | ||||||
|  |       code.attempts = code.attempts || []; | ||||||
|  |       code.attempts.unshift(new Date()); | ||||||
|  | 
 | ||||||
|  |       return codeStore.upsert(codeId, code).then(function () { | ||||||
|  |         return PromiseA.reject(err); | ||||||
|  |       }, function () { | ||||||
|  |         return PromiseA.reject(err); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function getOrCreate(store, username) { | ||||||
|  |   return store.IssuerOauth3OrgAccounts.get(username).then(function (account) { | ||||||
|  |     if (account) { | ||||||
|  |       return account; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     account = { | ||||||
|  |       username:  username, | ||||||
|  |       accountId: makeB64UrlSafe(crypto.randomBytes(32).toString('base64')), | ||||||
|  |     }; | ||||||
|  |     return store.IssuerOauth3OrgAccounts.create(username, account).then(function () { | ||||||
|  |       // TODO: put sort sort of email notification to the server managers?
 | ||||||
|  |       return account; | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | function getPrivKey(store, experienceId) { | ||||||
|  |   return store.IssuerOauth3OrgPrivateKeys.get(experienceId).then(function (jwk) { | ||||||
|  |     if (jwk) { | ||||||
|  |       return jwk; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var keyPair = require('elliptic').ec('p256').genKeyPair(); | ||||||
|  |     jwk = { | ||||||
|  |       kty: 'EC', | ||||||
|  |       crv: 'P-256', | ||||||
|  |       alg: 'ES256', | ||||||
|  |       kid: experienceId, | ||||||
|  |       x: makeB64UrlSafe(keyPair.getPublic().getX().toArrayLike(Buffer).toString('base64')), | ||||||
|  |       y: makeB64UrlSafe(keyPair.getPublic().getY().toArrayLike(Buffer).toString('base64')), | ||||||
|  |       d: makeB64UrlSafe(keyPair.getPrivate().toArrayLike(Buffer).toString('base64')), | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     return store.IssuerOauth3OrgPrivateKeys.upsert(experienceId, jwk).then(function () { | ||||||
|  |       return jwk; | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function timespan(duration, max) { | ||||||
|  |   var timestamp = Math.floor(Date.now() / 1000); | ||||||
|  | 
 | ||||||
|  |   if (!duration) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   if (typeof duration === 'string') { | ||||||
|  |     duration = Math.floor(require('ms')(duration) / 1000) || 0; | ||||||
|  |   } | ||||||
|  |   if (typeof duration !== 'number') { | ||||||
|  |     return 0; | ||||||
|  |   } | ||||||
|  |   // Handle the case where the user gave us a timestamp instead of duration for the expiration.
 | ||||||
|  |   // Also make the maximum explicitly defined expiration as one year.
 | ||||||
|  |   if (duration > 31557600) { | ||||||
|  |     if (duration > timestamp) { | ||||||
|  |       return duration - timestamp; | ||||||
|  |     } else { | ||||||
|  |       return 31557600; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   if (max && timestamp+duration > max) { | ||||||
|  |     return max - timestamp; | ||||||
|  |   } | ||||||
|  |   return duration; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function create(app) { | ||||||
|  |   var restful = {}; | ||||||
|  | 
 | ||||||
|  |   restful.sendOtp = function (req, res) { | ||||||
|  |     var params = req.body; | ||||||
|  |     var promise = PromiseA.resolve().then(function () { | ||||||
|  |       if (!params || !params.username) { | ||||||
|  |         throw new OpErr("must provide the email address as 'username' in the body"); | ||||||
|  |       } | ||||||
|  |       if ((params.username_type && 'email' !== params.username_type) || !/@/.test(params.username)) { | ||||||
|  |         throw new OpErr("only email one-time login codes are supported at this time"); | ||||||
|  |       } | ||||||
|  |       params.username_type = 'email'; | ||||||
|  | 
 | ||||||
|  |       return req.getSiteStore(); | ||||||
|  |     }).then(function (store) { | ||||||
|  |       var codeStore = store.IssuerOauth3OrgCodes; | ||||||
|  |       var codeId = crypto.createHash('sha256').update(params.username_type+':'+params.username).digest('base64'); | ||||||
|  |       codeId = makeB64UrlSafe(codeId); | ||||||
|  | 
 | ||||||
|  |       return retrieveOtp(codeStore, codeId).then(function (code) { | ||||||
|  |         if (code) { | ||||||
|  |           return code; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         var token = ''; | ||||||
|  |         while (!/^\d{4}-\d{4}-\d{4}$/.test(token)) { | ||||||
|  |           // Most of the number we can generate this was start with 1 (and no matter what can't
 | ||||||
|  |           // start with 0), so we don't use the very first digit. Also basically all of the
 | ||||||
|  |           // numbers are too big to accurately store in JS floats, so we limit the trailing 0's.
 | ||||||
|  |           token = (parseInt(crypto.randomBytes(8).toString('hex'), 16)).toString() | ||||||
|  |             .replace(/0+$/, '0').replace(/\d(\d{4})(\d{4})(\d{4}).*/, '$1-$2-$3'); | ||||||
|  |         } | ||||||
|  |         code = { | ||||||
|  |           id: codeId, | ||||||
|  |           code: token, | ||||||
|  |           expires: new Date(Date.now() + 20*60*1000), | ||||||
|  |         }; | ||||||
|  |         return codeStore.upsert(codeId, code).then(function (){ | ||||||
|  |           return code; | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |     }).then(function (code) { | ||||||
|  |       var emailParams = { | ||||||
|  |         to:      params.username, | ||||||
|  |         from:    'login@daplie.com',   // opts.mailer.defaults.system
 | ||||||
|  |         replyTo: 'hello@daplie.com', | ||||||
|  |         subject: "Use " + code.code + " as your Login Code",  // message.Subject
 | ||||||
|  |         text:    code.code + " is your Login Code."        // message['stripped-html']
 | ||||||
|  |       }; | ||||||
|  |       emailParams['h:Reply-To'] = emailParams.replyTo; | ||||||
|  | 
 | ||||||
|  |       return req.getSiteMailer().sendMailAsync(emailParams).then(function () { | ||||||
|  |         return { | ||||||
|  |           code_id: code.id, | ||||||
|  |           expires: code.expires, | ||||||
|  |           created: new Date(parseInt(code.createdAt, 10) || code.createdAt), | ||||||
|  |         }; | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     app.handlePromise(req, res, promise, '[issuer@oauth3.org] send one-time-password'); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   restful.createToken = function (req, res) { | ||||||
|  |     var store; | ||||||
|  |     var promise = req.getSiteStore().then(function (_store) { | ||||||
|  |       store = _store; | ||||||
|  |       if (!req.body || !req.body.grant_type) { | ||||||
|  |         throw new OpErr("missing 'grant_type' from the body"); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       if (req.body.grant_type === 'password') { | ||||||
|  |         restful.createToken.password(req); | ||||||
|  |       } | ||||||
|  |       if (req.body.grant_type === 'issuer_token') { | ||||||
|  |         restful.createToken.issuerToken(req); | ||||||
|  |       } | ||||||
|  |       if (req.body.grant_type === 'refresh_token') { | ||||||
|  |         restful.createToken.refreshToken(req); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       throw new OpErr("unknown or un-implemented grant_type '"+req.body.grant_type+"'"); | ||||||
|  |     }).then(function (token_info) { | ||||||
|  |       token_info.iss = req.experienceId; | ||||||
|  |       if (!token_info.aud) { | ||||||
|  |         throw new OpErr("missing required token field 'aud'"); | ||||||
|  |       } | ||||||
|  |       if (!token_info.azp) { | ||||||
|  |         throw new OpErr("missing required token field 'azp'"); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       if (token_info.iss === token_info.azp) { | ||||||
|  |         // We don't have normal grants for the issuer, so we don't need to look the
 | ||||||
|  |         // azpSub or the grants up in the database.
 | ||||||
|  |         token_info.azpSub = token_info.sub; | ||||||
|  |         token_info.scope = ''; | ||||||
|  |         return token_info; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       var search = {}; | ||||||
|  |       ['sub', 'azp', 'azpSub'].forEach(function (key) { | ||||||
|  |         if (token_info[key]) { | ||||||
|  |           search[key] = token_info[key]; | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |       return store.IssuerOauth3OrgGrants.find(search).then(function (grants) { | ||||||
|  |         if (!grants.length) { | ||||||
|  |           throw new OpErr("'"+token_info.azp+"' not given any grants from '"+(token_info.sub || token_info.azpSub)+"'"); | ||||||
|  |         } | ||||||
|  |         if (grants.length > 1) { | ||||||
|  |           throw new Error("unexpected resource collision: too many relevant grants"); | ||||||
|  |         } | ||||||
|  |         var grant = grants[0]; | ||||||
|  |         Object.keys(grant).forEach(function (key) { | ||||||
|  |           token_info[key] = grant[key]; | ||||||
|  |         }); | ||||||
|  |         return token_info; | ||||||
|  |       }); | ||||||
|  |     }).then(function (token_info) { | ||||||
|  |       return getPrivKey(store, req.experienceId).then(function (jwk) { | ||||||
|  |         var pem = require('jwk-to-pem')(jwk, { private: true }); | ||||||
|  |         var payload =  { | ||||||
|  |           // standard
 | ||||||
|  |           iss: token_info.iss,    // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.1
 | ||||||
|  |           aud: token_info.aud,    // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.3
 | ||||||
|  |           azp: token_info.azp, | ||||||
|  |           sub: token_info.azpSub, // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.2
 | ||||||
|  |           // extended
 | ||||||
|  |           scp: token_info.scope, | ||||||
|  |         }; | ||||||
|  |         var opts = { | ||||||
|  |           algorithm: jwk.alg, | ||||||
|  |           header: { | ||||||
|  |             kid: jwk.kid | ||||||
|  |           } | ||||||
|  |         }; | ||||||
|  |         var accessOpts  = {}; | ||||||
|  |         // We set `expiresIn` like this to make it possible to send `null` and `exp` to have
 | ||||||
|  |         // no expiration while still having a default of 1 day.
 | ||||||
|  |         if (req.body.hasOwnProperty('exp')) { | ||||||
|  |           accessOpts.expiresIn = timespan(req.body.exp, token_info.exp); | ||||||
|  |         } else { | ||||||
|  |           accessOpts.expiresIn = timespan('1d', token_info.exp); | ||||||
|  |         } | ||||||
|  |         var refreshOpts = {}; | ||||||
|  |         refreshOpts.expiresIn = timespan(req.body.refresh_exp, token_info.exp); | ||||||
|  | 
 | ||||||
|  |         var jwt = require('jsonwebtoken'); | ||||||
|  |         var result = {}; | ||||||
|  |         result.scope = token_info.scope; | ||||||
|  |         result.access_token = jwt.sign(payload, pem, Object.assign(accessOpts, opts)); | ||||||
|  |         if (req.body.refresh_token) { | ||||||
|  |           if (token_info.refresh_token) { | ||||||
|  |             result.refresh_token = token_info.refresh_token; | ||||||
|  |           } else { | ||||||
|  |             result.refresh_token = jwt.sign(payload, pem, Object.assign(refreshOpts, opts)); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |         return result; | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     app.handlePromise(req, res, promise, '[issuer@oauth3.org] create tokens'); | ||||||
|  |   }; | ||||||
|  |   restful.createToken.password = function (req) { | ||||||
|  |     var params = req.body; | ||||||
|  |     if (!params || !params.username) { | ||||||
|  |       return PromiseA.reject(PromiseA.OperationalError("must provide the email address as 'username' in the body")); | ||||||
|  |     } | ||||||
|  |     if ((params.username_type && 'email' !== params.username_type) || !/@/.test(params.username)) { | ||||||
|  |       return PromiseA.reject(PromiseA.OperationalError("only email one-time login codes are supported at this time")); | ||||||
|  |     } | ||||||
|  |     params.username_type = 'email'; | ||||||
|  |     if (!params.password) { | ||||||
|  |       return PromiseA.reject(new OpErr("request missing 'password'")); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var codeId = crypto.createHash('sha256').update(params.username_type+':'+params.username).digest('base64'); | ||||||
|  |     codeId = makeB64UrlSafe(codeId); | ||||||
|  |     return req.getSiteStore().then(function (store) { | ||||||
|  |       return validateOtp(store.IssuerOauth3OrgCodes, codeId, params.password) | ||||||
|  |       .then(function () { | ||||||
|  |         return getOrCreate(store, params.username); | ||||||
|  |       }).then(function (account) { | ||||||
|  |         return { | ||||||
|  |           sub: account.accountId, | ||||||
|  |           aud: req.params.aud || req.body.aud || req.experienceId, | ||||||
|  |           azp: req.params.azp || req.body.azp || req.experienceId, | ||||||
|  |         }; | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |   restful.createToken.issuerToken = function (req) { | ||||||
|  |     return require('./common').checkIsserToken(req, req.params.sub || req.body.sub).then(function (sub) { | ||||||
|  |       return { | ||||||
|  |         sub: sub, | ||||||
|  |         aud: req.params.aud || req.body.aud, | ||||||
|  |         azp: req.params.azp || req.body.azp, | ||||||
|  |         exp: req.oauth3.token.exp, | ||||||
|  |       }; | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |   restful.createToken.refreshToken = function (req) { | ||||||
|  |     return PromiseA.resolve().then(function () { | ||||||
|  |       if (!req.body.refresh_token) { | ||||||
|  |         throw new OpErr("missing refresh token"); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       return req.oauth3.verifyAsync(req.body.refresh_token).then(function (token) { | ||||||
|  |         return { | ||||||
|  |           sub: token.sub, | ||||||
|  |           aud: token.aud, | ||||||
|  |           azp: token.azp, | ||||||
|  |           exp: token.exp, | ||||||
|  |           refresh_token: req.body.refresh_token, | ||||||
|  |         }; | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | module.exports.create = create; | ||||||
							
								
								
									
										39
									
								
								common.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								common.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,39 @@ | |||||||
|  | 'use strict'; | ||||||
|  | 
 | ||||||
|  | var PromiseA = require('bluebird'); | ||||||
|  | var OpErr = PromiseA.OperationalError; | ||||||
|  | 
 | ||||||
|  | function checkIsserToken(req, expectedSub) { | ||||||
|  |   if (!req.oauth3 || !req.oauth3.verifyAsync) { | ||||||
|  |     return PromiseA.reject(new OpErr("request requires a token for authorization")); | ||||||
|  |   } | ||||||
|  |   return req.oauth3.verifyAsync().then(function (token) { | ||||||
|  |     // Now that we've confirmed the token is valid we also need to make sure the issuer, audience,
 | ||||||
|  |     // and authorized party are all us, because no other app should be managing user identity.
 | ||||||
|  |     if (token.iss !== req.experienceId || token.aud !== token.iss || token.azp !== token.iss) { | ||||||
|  |       throw new OpErr("token does not allow access to requested resource"); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var sub = token.sub || token.ppid || (token.acx && (token.acx.id || token.acx.appScopedId)); | ||||||
|  |     if (!sub) { | ||||||
|  |       if (!expectedSub || !Array.isArray(token.axs) || !token.axs.length) { | ||||||
|  |         throw new OpErr("no account pairwise identifier"); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       var allowed = token.axs.some(function (acc) { | ||||||
|  |         return expectedSub === (acc.id || acc.ppid || acc.appScopedId); | ||||||
|  |       }); | ||||||
|  |       if (!allowed) { | ||||||
|  |         throw new OpErr("no account pairwise identifier matching '" + expectedSub + "'"); | ||||||
|  |       } | ||||||
|  |       sub = expectedSub; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (expectedSub && expectedSub !== sub) { | ||||||
|  |       throw new OpErr("token does not allow access to resources for '"+expectedSub+"'"); | ||||||
|  |     } | ||||||
|  |     return sub; | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | module.exports.checkIsserToken = checkIsserToken; | ||||||
							
								
								
									
										76
									
								
								grants.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								grants.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,76 @@ | |||||||
|  | 'use strict'; | ||||||
|  | 
 | ||||||
|  | var PromiseA = require('bluebird'); | ||||||
|  | var OpErr = PromiseA.OperationalError; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | function trim(grant) { | ||||||
|  |   return { | ||||||
|  |     sub:   grant.sub, | ||||||
|  |     azp:   grant.azp, | ||||||
|  |     // azpSub: grant.azpSub,
 | ||||||
|  |     scope: grant.scope, | ||||||
|  |     updatedAt: parseInt(grant.updatedAt, 10), | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function create(app) { | ||||||
|  |   var restful; | ||||||
|  | 
 | ||||||
|  |   restful.getOne = function (req, res) { | ||||||
|  |     var promise = req.Store.get(req.params.sub+'/'+req.params.azp).then(function (grant) { | ||||||
|  |       if (!grant) { | ||||||
|  |         throw new OpErr('no grants found'); | ||||||
|  |       } | ||||||
|  |       return trim(grant); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve grants"); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   restful.getAll = function (req, res) { | ||||||
|  |     var promise = req.Store.find({ sub: req.params.sub }).then(function (results) { | ||||||
|  |       return results.map(trim).sort(function (grantA, grantB) { | ||||||
|  |         return (grantA.azp < grantB.azp) ? -1 : 1; | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve grants"); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   restful.saveNew = function (req, res) { | ||||||
|  |     var promise = PromiseA.resolve().then(function () { | ||||||
|  |       if (typeof req.body.scope !== 'string' || typeof req.body.sub !== 'string') { | ||||||
|  |         throw new OpErr("malformed request: 'sub' and 'scope' must be strings"); | ||||||
|  |       } | ||||||
|  |       return req.Store.find({ azpSub: req.body.sub }); | ||||||
|  |     }).then(function (existing) { | ||||||
|  |       if (existing.length) { | ||||||
|  |         if (existing.length > 1) { | ||||||
|  |           throw new OpErr("pre-existing PPID collision detected"); | ||||||
|  |         } else if (existing[0].sub !== req.params.sub || existing[0].azp !== req.params.azp) { | ||||||
|  |           throw new OpErr("PPID collision detected, cannot save authorized party's sub"); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       var grant = { | ||||||
|  |         sub:    req.params.sub, | ||||||
|  |         azp:    req.params.azp, | ||||||
|  |         azpSub: req.body.sub, | ||||||
|  |         scope:  req.body.scope.split(/[+ ,]+/g).join(','), | ||||||
|  |       }; | ||||||
|  |       return req.Store.upsert(grant.sub+'/'+grant.azp, grant); | ||||||
|  |     }).then(function () { | ||||||
|  |       return {success: true}; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     app.handlePromise(req, res, promise, '[issuer@oauth3.org] save grants'); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   return { | ||||||
|  |     trim:    trim, | ||||||
|  |     restful: restful, | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | module.exports.create = create; | ||||||
							
								
								
									
										109
									
								
								jwks.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								jwks.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,109 @@ | |||||||
|  | 'use strict'; | ||||||
|  | 
 | ||||||
|  | var crypto = require('crypto'); | ||||||
|  | var PromiseA = require('bluebird'); | ||||||
|  | var OpErr = PromiseA.OperationalError; | ||||||
|  | 
 | ||||||
|  | function makeB64UrlSafe(b64) { | ||||||
|  |   return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/, ''); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function thumbprint(jwk) { | ||||||
|  |   // To produce a thumbprint we need to create a JSON string with only the required keys for
 | ||||||
|  |   // the key type, with the keys sorted lexicographically and no white space. We then need
 | ||||||
|  |   // run it through a SHA-256 and encode the result in url safe base64.
 | ||||||
|  |   // https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk
 | ||||||
|  |   var keys; | ||||||
|  |   if (jwk.kty === 'EC') { | ||||||
|  |     keys = ['crv', 'x', 'y']; | ||||||
|  |   } else if (jwk.kty === 'RSA') { | ||||||
|  |     keys = ['e', 'n']; | ||||||
|  |   } else { | ||||||
|  |     return PromiseA.reject(new Error('invalid JWK key type ' + jwk.kty)); | ||||||
|  |   } | ||||||
|  |   keys.push('kty'); | ||||||
|  |   keys.sort(); | ||||||
|  | 
 | ||||||
|  |   var missing = keys.filter(function (name) { return !jwk.hasOwnProperty(name); }); | ||||||
|  |   if (missing.length > 0) { | ||||||
|  |     return PromiseA.reject(new Error('JWK of type '+jwk.kty+' missing fields ' + missing)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // I'm not 100% sure this behavior is guaranteed by a real standard, but when we use an array
 | ||||||
|  |   // as the replacer argument the keys are always in the order they appeared in the array.
 | ||||||
|  |   var jwkStr = JSON.stringify(jwk, keys); | ||||||
|  |   var hash = crypto.createHash('sha256').update(jwkStr).digest('base64'); | ||||||
|  |   return PromiseA.resolve(makeB64UrlSafe(hash)); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function create(app) { | ||||||
|  |   var restful = {}; | ||||||
|  |   restful.get = function (req, res) { | ||||||
|  |     // The sub in params is the 3rd party PPID, but the keys are stored by the issuer PPID, so
 | ||||||
|  |     // we need to look up the issuer PPID using the 3rd party PPID.
 | ||||||
|  |     var promise = req.getSiteStore().then(function (store) { | ||||||
|  |       if (req.params.kid === req.experienceId) { | ||||||
|  |         return store.IssuerOauth3OrgPrivateKeys.get(req.experienceId); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       return store.IssuerOauth3OrgGrants.find({ azpSub: req.params.sub }).then(function (results) { | ||||||
|  |         if (!results.length) { | ||||||
|  |           throw new OpErr("unknown PPID '"+req.params.sub+"'"); | ||||||
|  |         } | ||||||
|  |         if (results.length > 1) { | ||||||
|  |           // This should not ever happen since there is a check for PPID collisions when saving
 | ||||||
|  |           // grants, but it's probably better to have this check anyway just incase something
 | ||||||
|  |           // happens that isn't currently accounted for.
 | ||||||
|  |           throw new OpErr('PPID collision - unable to safely retrieve keys'); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return store.IssuerOauth3OrgJwks.get(results[0].sub+'/'+req.params.kid); | ||||||
|  |       }); | ||||||
|  |     }).then(function (jwk) { | ||||||
|  |       if (!jwk) { | ||||||
|  |         throw new OpErr("no keys stored with kid '"+req.params.kid+"' for PPID "+req.params.sub); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       // We need to sanitize the key to make sure we don't deliver any private keys fields if
 | ||||||
|  |       // we were given a key we could use to sign tokens on behalf of the user. We also don't
 | ||||||
|  |       // want to deliver the sub or any other PPIDs.
 | ||||||
|  |       var whitelist = [ 'kty', 'alg', 'kid', 'use' ]; | ||||||
|  |       if (jwk.kty === 'EC') { | ||||||
|  |         whitelist = whitelist.concat([ 'crv', 'x', 'y' ]); | ||||||
|  |       } else if (jwk.kty === 'RSA') { | ||||||
|  |         whitelist = whitelist.concat([ 'e', 'n' ]); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       var result = {}; | ||||||
|  |       whitelist.forEach(function (key) { | ||||||
|  |         result[key] = jwk[key]; | ||||||
|  |       }); | ||||||
|  |       return result; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve JWK"); | ||||||
|  |   }; | ||||||
|  |   restful.saveNew = function (req, res) { | ||||||
|  |     var jwk = req.body; | ||||||
|  |     var promise = thumbprint(jwk).then(function (kid) { | ||||||
|  |       if (jwk.kid && jwk.kid !== kid) { | ||||||
|  |         throw new OpErr('provided kid "'+jwk.kid+'" does not match calculated "'+kid+'"'); | ||||||
|  |       } | ||||||
|  |       jwk.kid = kid; | ||||||
|  |       jwk.sub = req.params.sub; | ||||||
|  | 
 | ||||||
|  |       return req.Store.upsert(jwk.sub+'/'+jwk.kid, jwk); | ||||||
|  |     }).then(function () { | ||||||
|  |       return { success: true }; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     app.handlePromise(req, res, promise, "[issuer@oauth3.org] save JWK"); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   return { | ||||||
|  |     thumbprint: thumbprint, | ||||||
|  |     restful: restful, | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | module.exports.create = create; | ||||||
							
								
								
									
										539
									
								
								rest.js
									
									
									
									
									
								
							
							
						
						
									
										539
									
								
								rest.js
									
									
									
									
									
								
							| @ -1,44 +1,8 @@ | |||||||
| 'use strict'; | 'use strict'; | ||||||
| 
 | 
 | ||||||
| var PromiseA = require('bluebird'); |  | ||||||
| var crypto = require('crypto'); |  | ||||||
| var OpErr = PromiseA.OperationalError; |  | ||||||
| 
 |  | ||||||
| function makeB64UrlSafe(b64) { |  | ||||||
|   return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/, ''); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function timespan(duration, max) { |  | ||||||
|   var timestamp = Math.floor(Date.now() / 1000); |  | ||||||
| 
 |  | ||||||
|   if (!duration) { |  | ||||||
|     return; |  | ||||||
|   } |  | ||||||
|   if (typeof duration === 'string') { |  | ||||||
|     duration = Math.floor(require('ms')(duration) / 1000) || 0; |  | ||||||
|   } |  | ||||||
|   if (typeof duration !== 'number') { |  | ||||||
|     return 0; |  | ||||||
|   } |  | ||||||
|   // Handle the case where the user gave us a timestamp instead of duration for the expiration.
 |  | ||||||
|   // Also make the maximum explicitly defined expiration as one year.
 |  | ||||||
|   if (duration > 31557600) { |  | ||||||
|     if (duration > timestamp) { |  | ||||||
|       return duration - timestamp; |  | ||||||
|     } else { |  | ||||||
|       return 31557600; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (max && timestamp+duration > max) { |  | ||||||
|     return max - timestamp; |  | ||||||
|   } |  | ||||||
|   return duration; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| module.exports.create = function (bigconf, deps, app) { | module.exports.create = function (bigconf, deps, app) { | ||||||
|   var Jwks = { restful: {} }; |   var Jwks = require('./jwks').create(app); | ||||||
|   var Grants = { restful: {} }; |   var Grants = require('./grants').create(app); | ||||||
|   var Accounts = { restful: {} }; |   var Accounts = { restful: {} }; | ||||||
| 
 | 
 | ||||||
|   // This tablename is based on the tablename found in the objects in model.js.
 |   // This tablename is based on the tablename found in the objects in model.js.
 | ||||||
| @ -53,511 +17,14 @@ module.exports.create = function (bigconf, deps, app) { | |||||||
|     delete req.Store; |     delete req.Store; | ||||||
|     next(); |     next(); | ||||||
|   } |   } | ||||||
| 
 |  | ||||||
|   function checkIsserToken(req, expectedSub) { |  | ||||||
|     if (!req.oauth3 || !req.oauth3.verifyAsync) { |  | ||||||
|       return PromiseA.reject(new OpErr("request requires a token for authorization")); |  | ||||||
|     } |  | ||||||
|     return req.oauth3.verifyAsync().then(function (token) { |  | ||||||
|       // Now that we've confirmed the token is valid we also need to make sure the issuer, audience,
 |  | ||||||
|       // and authorized party are all us, because no other app should be managing user identity.
 |  | ||||||
|       if (token.iss !== req.experienceId || token.aud !== token.iss || token.azp !== token.iss) { |  | ||||||
|         throw new OpErr("token does not allow access to requested resource"); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       var sub = token.sub || token.ppid || (token.acx && (token.acx.id || token.acx.appScopedId)); |  | ||||||
|       if (!sub) { |  | ||||||
|         if (!expectedSub || !Array.isArray(token.axs) || !token.axs.length) { |  | ||||||
|           throw new OpErr("no account pairwise identifier"); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         var allowed = token.axs.some(function (acc) { |  | ||||||
|           return expectedSub === (acc.id || acc.ppid || acc.appScopedId); |  | ||||||
|         }); |  | ||||||
|         if (!allowed) { |  | ||||||
|           throw new OpErr("no account pairwise identifier matching '" + expectedSub + "'"); |  | ||||||
|         } |  | ||||||
|         sub = expectedSub; |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       if (expectedSub && expectedSub !== sub) { |  | ||||||
|         throw new OpErr("token does not allow access to resources for '"+expectedSub+"'"); |  | ||||||
|       } |  | ||||||
|       return sub; |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|   function authorizeIssuer(req, res, next) { |   function authorizeIssuer(req, res, next) { | ||||||
|     var promise = checkIsserToken(req, req.params.sub).then(function () { |     var promise = require('./common').checkIsserToken(req, req.params.sub).then(function () { | ||||||
|       next(); |       next(); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     app.handleRejection(req, res, promise, '[issuer@oauth3.org] authorize req as issuer'); |     app.handleRejection(req, res, promise, '[issuer@oauth3.org] authorize req as issuer'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|   Jwks.thumbprint = function (jwk) { |  | ||||||
|     // To produce a thumbprint we need to create a JSON string with only the required keys for
 |  | ||||||
|     // the key type, with the keys sorted lexicographically and no white space. We then need
 |  | ||||||
|     // run it through a SHA-256 and encode the result in url safe base64.
 |  | ||||||
|     // https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk
 |  | ||||||
|     var keys; |  | ||||||
|     if (jwk.kty === 'EC') { |  | ||||||
|       keys = ['crv', 'x', 'y']; |  | ||||||
|     } else if (jwk.kty === 'RSA') { |  | ||||||
|       keys = ['e', 'n']; |  | ||||||
|     } else { |  | ||||||
|       return PromiseA.reject(new Error('invalid JWK key type ' + jwk.kty)); |  | ||||||
|     } |  | ||||||
|     keys.push('kty'); |  | ||||||
|     keys.sort(); |  | ||||||
| 
 |  | ||||||
|     var missing = keys.filter(function (name) { return !jwk.hasOwnProperty(name); }); |  | ||||||
|     if (missing.length > 0) { |  | ||||||
|       return PromiseA.reject(new Error('JWK of type '+jwk.kty+' missing fields ' + missing)); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // I'm not 100% sure this behavior is guaranteed by a real standard, but when we use an array
 |  | ||||||
|     // as the replacer argument the keys are always in the order they appeared in the array.
 |  | ||||||
|     var jwkStr = JSON.stringify(jwk, keys); |  | ||||||
|     var hash = crypto.createHash('sha256').update(jwkStr).digest('base64'); |  | ||||||
|     return PromiseA.resolve(makeB64UrlSafe(hash)); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   Jwks.restful.get = function (req, res) { |  | ||||||
|     // The sub in params is the 3rd party PPID, but the keys are stored by the issuer PPID, so
 |  | ||||||
|     // we need to look up the issuer PPID using the 3rd party PPID.
 |  | ||||||
|     var promise = req.getSiteStore().then(function (store) { |  | ||||||
|       if (req.params.kid === req.experienceId) { |  | ||||||
|         return store.IssuerOauth3OrgPrivateKeys.get(req.experienceId); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       return store.IssuerOauth3OrgGrants.find({ azpSub: req.params.sub }).then(function (results) { |  | ||||||
|         if (!results.length) { |  | ||||||
|           throw new OpErr("unknown PPID '"+req.params.sub+"'"); |  | ||||||
|         } |  | ||||||
|         if (results.length > 1) { |  | ||||||
|           // This should not ever happen since there is a check for PPID collisions when saving
 |  | ||||||
|           // grants, but it's probably better to have this check anyway just incase something
 |  | ||||||
|           // happens that isn't currently accounted for.
 |  | ||||||
|           throw new OpErr('PPID collision - unable to safely retrieve keys'); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return store.IssuerOauth3OrgJwks.get(results[0].sub+'/'+req.params.kid); |  | ||||||
|       }); |  | ||||||
|     }).then(function (jwk) { |  | ||||||
|       if (!jwk) { |  | ||||||
|         throw new OpErr("no keys stored with kid '"+req.params.kid+"' for PPID "+req.params.sub); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       // We need to sanitize the key to make sure we don't deliver any private keys fields if
 |  | ||||||
|       // we were given a key we could use to sign tokens on behalf of the user. We also don't
 |  | ||||||
|       // want to deliver the sub or any other PPIDs.
 |  | ||||||
|       var whitelist = [ 'kty', 'alg', 'kid', 'use' ]; |  | ||||||
|       if (jwk.kty === 'EC') { |  | ||||||
|         whitelist = whitelist.concat([ 'crv', 'x', 'y' ]); |  | ||||||
|       } else if (jwk.kty === 'RSA') { |  | ||||||
|         whitelist = whitelist.concat([ 'e', 'n' ]); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       var result = {}; |  | ||||||
|       whitelist.forEach(function (key) { |  | ||||||
|         result[key] = jwk[key]; |  | ||||||
|       }); |  | ||||||
|       return result; |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve JWK"); |  | ||||||
|   }; |  | ||||||
|   Jwks.restful.saveNew = function (req, res) { |  | ||||||
|     var jwk = req.body; |  | ||||||
|     var promise = Jwks.thumbprint(jwk).then(function (kid) { |  | ||||||
|       if (jwk.kid && jwk.kid !== kid) { |  | ||||||
|         throw new OpErr('provided kid "'+jwk.kid+'" does not match calculated "'+kid+'"'); |  | ||||||
|       } |  | ||||||
|       jwk.kid = kid; |  | ||||||
|       jwk.sub = req.params.sub; |  | ||||||
| 
 |  | ||||||
|       return req.Store.upsert(jwk.sub+'/'+jwk.kid, jwk); |  | ||||||
|     }).then(function () { |  | ||||||
|       return { success: true }; |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     app.handlePromise(req, res, promise, "[issuer@oauth3.org] save JWK"); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|   Grants.trim = function (grant) { |  | ||||||
|     return { |  | ||||||
|       sub:   grant.sub, |  | ||||||
|       azp:   grant.azp, |  | ||||||
|       // azpSub: grant.azpSub,
 |  | ||||||
|       scope: grant.scope, |  | ||||||
|       updatedAt: parseInt(grant.updatedAt, 10), |  | ||||||
|     }; |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   Grants.restful.getOne = function (req, res) { |  | ||||||
|     var promise = req.Store.get(req.params.sub+'/'+req.params.azp).then(function (grant) { |  | ||||||
|       if (!grant) { |  | ||||||
|         throw new OpErr('no grants found'); |  | ||||||
|       } |  | ||||||
|       return Grants.trim(grant); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve grants"); |  | ||||||
|   }; |  | ||||||
|   Grants.restful.getAll = function (req, res) { |  | ||||||
|     var promise = req.Store.find({ sub: req.params.sub }).then(function (results) { |  | ||||||
|       return results.map(Grants.trim).sort(function (grantA, grantB) { |  | ||||||
|         return (grantA.azp < grantB.azp) ? -1 : 1; |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve grants"); |  | ||||||
|   }; |  | ||||||
|   Grants.restful.saveNew = function (req, res) { |  | ||||||
|     var promise = PromiseA.resolve().then(function () { |  | ||||||
|       if (typeof req.body.scope !== 'string' || typeof req.body.sub !== 'string') { |  | ||||||
|         throw new OpErr("malformed request: 'sub' and 'scope' must be strings"); |  | ||||||
|       } |  | ||||||
|       return req.Store.find({ azpSub: req.body.sub }); |  | ||||||
|     }).then(function (existing) { |  | ||||||
|       if (existing.length) { |  | ||||||
|         if (existing.length > 1) { |  | ||||||
|           throw new OpErr("pre-existing PPID collision detected"); |  | ||||||
|         } else if (existing[0].sub !== req.params.sub || existing[0].azp !== req.params.azp) { |  | ||||||
|           throw new OpErr("PPID collision detected, cannot save authorized party's sub"); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       var grant = { |  | ||||||
|         sub:    req.params.sub, |  | ||||||
|         azp:    req.params.azp, |  | ||||||
|         azpSub: req.body.sub, |  | ||||||
|         scope:  req.body.scope.split(/[+ ,]+/g).join(','), |  | ||||||
|       }; |  | ||||||
|       return req.Store.upsert(grant.sub+'/'+grant.azp, grant); |  | ||||||
|     }).then(function () { |  | ||||||
|       return {success: true}; |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     app.handlePromise(req, res, promise, '[issuer@oauth3.org] save grants'); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|   Accounts.retrieveOtp = function (codeStore, codeId) { |  | ||||||
|     return codeStore.get(codeId).then(function (code) { |  | ||||||
|       if (!code) { |  | ||||||
|         return null; |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       var expires = (new Date(code.expires)).valueOf(); |  | ||||||
|       if (!expires || Date.now() > expires) { |  | ||||||
|         return codeStore.destroy(codeId).then(function () { |  | ||||||
|           return null; |  | ||||||
|         }); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       return code; |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
|   Accounts.validateOtp = function (codeStore, codeId, token) { |  | ||||||
|     if (!codeId) { |  | ||||||
|       return PromiseA.reject(new Error("Must provide authcode ID")); |  | ||||||
|     } |  | ||||||
|     if (!token) { |  | ||||||
|       return PromiseA.reject(new Error("Must provide authcode code")); |  | ||||||
|     } |  | ||||||
|     return codeStore.get(codeId).then(function (code) { |  | ||||||
|       if (!code) { |  | ||||||
|         throw new OpErr('authcode specified does not exist or has expired'); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       return PromiseA.resolve().then(function () { |  | ||||||
|         var attemptsLeft = 3 - (code.attempts && code.attempts.length || 0); |  | ||||||
|         if (attemptsLeft <= 0) { |  | ||||||
|           throw new OpErr('you have tried to authorize this code too many times'); |  | ||||||
|         } |  | ||||||
|         if (code.code !== token) { |  | ||||||
|           throw new OpErr('you have entered the code incorrectly. '+attemptsLeft+' attempts remaining'); |  | ||||||
|         } |  | ||||||
|         // TODO: maybe impose a rate limit, although going fast doesn't help you break the
 |  | ||||||
|         // system when you can only try 3 times total.
 |  | ||||||
|       }).then(function () { |  | ||||||
|         return codeStore.destroy(codeId).then(function () { |  | ||||||
|           return code; |  | ||||||
|         }); |  | ||||||
|       }, function (err) { |  | ||||||
|         code.attempts = code.attempts || []; |  | ||||||
|         code.attempts.unshift(new Date()); |  | ||||||
| 
 |  | ||||||
|         return codeStore.upsert(codeId, code).then(function () { |  | ||||||
|           return PromiseA.reject(err); |  | ||||||
|         }, function () { |  | ||||||
|           return PromiseA.reject(err); |  | ||||||
|         }); |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   Accounts.getOrCreate = function (store, username) { |  | ||||||
|     return store.IssuerOauth3OrgAccounts.get(username).then(function (account) { |  | ||||||
|       if (account) { |  | ||||||
|         return account; |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       account = { |  | ||||||
|         username:  username, |  | ||||||
|         accountId: makeB64UrlSafe(crypto.randomBytes(32).toString('base64')), |  | ||||||
|       }; |  | ||||||
|       return store.IssuerOauth3OrgAccounts.create(username, account).then(function () { |  | ||||||
|         // TODO: put sort sort of email notification to the server managers?
 |  | ||||||
|         return account; |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
|   Accounts.getPrivKey = function (store, experienceId) { |  | ||||||
|     return store.IssuerOauth3OrgPrivateKeys.get(experienceId).then(function (jwk) { |  | ||||||
|       if (jwk) { |  | ||||||
|         return jwk; |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       var keyPair = require('elliptic').ec('p256').genKeyPair(); |  | ||||||
|       jwk = { |  | ||||||
|         kty: 'EC', |  | ||||||
|         crv: 'P-256', |  | ||||||
|         alg: 'ES256', |  | ||||||
|         kid: experienceId, |  | ||||||
|         x: makeB64UrlSafe(keyPair.getPublic().getX().toArrayLike(Buffer).toString('base64')), |  | ||||||
|         y: makeB64UrlSafe(keyPair.getPublic().getY().toArrayLike(Buffer).toString('base64')), |  | ||||||
|         d: makeB64UrlSafe(keyPair.getPrivate().toArrayLike(Buffer).toString('base64')), |  | ||||||
|       }; |  | ||||||
| 
 |  | ||||||
|       return store.IssuerOauth3OrgPrivateKeys.upsert(experienceId, jwk).then(function () { |  | ||||||
|         return jwk; |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   Accounts.restful.sendOtp = function (req, res) { |  | ||||||
|     var params = req.body; |  | ||||||
|     var promise = PromiseA.resolve().then(function () { |  | ||||||
|       if (!params || !params.username) { |  | ||||||
|         throw new OpErr("must provide the email address as 'username' in the body"); |  | ||||||
|       } |  | ||||||
|       if ((params.username_type && 'email' !== params.username_type) || !/@/.test(params.username)) { |  | ||||||
|         throw new OpErr("only email one-time login codes are supported at this time"); |  | ||||||
|       } |  | ||||||
|       params.username_type = 'email'; |  | ||||||
| 
 |  | ||||||
|       return req.getSiteStore(); |  | ||||||
|     }).then(function (store) { |  | ||||||
|       var codeStore = store.IssuerOauth3OrgCodes; |  | ||||||
|       var codeId = crypto.createHash('sha256').update(params.username_type+':'+params.username).digest('base64'); |  | ||||||
|       codeId = makeB64UrlSafe(codeId); |  | ||||||
| 
 |  | ||||||
|       return Accounts.retrieveOtp(codeStore, codeId).then(function (code) { |  | ||||||
|         if (code) { |  | ||||||
|           return code; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         var token = ''; |  | ||||||
|         while (!/^\d{4}-\d{4}-\d{4}$/.test(token)) { |  | ||||||
|           // Most of the number we can generate this was start with 1 (and no matter what can't
 |  | ||||||
|           // start with 0), so we don't use the very first digit. Also basically all of the
 |  | ||||||
|           // numbers are too big to accurately store in JS floats, so we limit the trailing 0's.
 |  | ||||||
|           token = (parseInt(crypto.randomBytes(8).toString('hex'), 16)).toString() |  | ||||||
|             .replace(/0+$/, '0').replace(/\d(\d{4})(\d{4})(\d{4}).*/, '$1-$2-$3'); |  | ||||||
|         } |  | ||||||
|         code = { |  | ||||||
|           id: codeId, |  | ||||||
|           code: token, |  | ||||||
|           expires: new Date(Date.now() + 20*60*1000), |  | ||||||
|         }; |  | ||||||
|         return codeStore.upsert(codeId, code).then(function (){ |  | ||||||
|           return code; |  | ||||||
|         }); |  | ||||||
|       }); |  | ||||||
|     }).then(function (code) { |  | ||||||
|       var emailParams = { |  | ||||||
|         to:      params.username, |  | ||||||
|         from:    'login@daplie.com',   // opts.mailer.defaults.system
 |  | ||||||
|         replyTo: 'hello@daplie.com', |  | ||||||
|         subject: "Use " + code.code + " as your Login Code",  // message.Subject
 |  | ||||||
|         text:    code.code + " is your Login Code."        // message['stripped-html']
 |  | ||||||
|       }; |  | ||||||
|       emailParams['h:Reply-To'] = emailParams.replyTo; |  | ||||||
| 
 |  | ||||||
|       return req.getSiteMailer().sendMailAsync(emailParams).then(function () { |  | ||||||
|         return { |  | ||||||
|           code_id: code.id, |  | ||||||
|           expires: code.expires, |  | ||||||
|           created: new Date(parseInt(code.createdAt, 10) || code.createdAt), |  | ||||||
|         }; |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     app.handlePromise(req, res, promise, '[issuer@oauth3.org] send one-time-password'); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   Accounts.restful.createToken = function (req, res) { |  | ||||||
|     var store; |  | ||||||
|     var promise = req.getSiteStore().then(function (_store) { |  | ||||||
|       store = _store; |  | ||||||
|       if (!req.body || !req.body.grant_type) { |  | ||||||
|         throw new OpErr("missing 'grant_type' from the body"); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       if (req.body.grant_type === 'password') { |  | ||||||
|         return Accounts.restful.createToken.password(req); |  | ||||||
|       } |  | ||||||
|       if (req.body.grant_type === 'issuer_token') { |  | ||||||
|         return Accounts.restful.createToken.issuerToken(req); |  | ||||||
|       } |  | ||||||
|       if (req.body.grant_type === 'refresh_token') { |  | ||||||
|         return Accounts.restful.createToken.refreshToken(req); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       throw new OpErr("unknown or un-implemented grant_type '"+req.body.grant_type+"'"); |  | ||||||
|     }).then(function (token_info) { |  | ||||||
|       token_info.iss = req.experienceId; |  | ||||||
|       if (!token_info.aud) { |  | ||||||
|         throw new OpErr("missing required token field 'aud'"); |  | ||||||
|       } |  | ||||||
|       if (!token_info.azp) { |  | ||||||
|         throw new OpErr("missing required token field 'azp'"); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       if (token_info.iss === token_info.azp) { |  | ||||||
|         // We don't have normal grants for the issuer, so we don't need to look the
 |  | ||||||
|         // azpSub or the grants up in the database.
 |  | ||||||
|         token_info.azpSub = token_info.sub; |  | ||||||
|         token_info.scope = ''; |  | ||||||
|         return token_info; |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       var search = {}; |  | ||||||
|       ['sub', 'azp', 'azpSub'].forEach(function (key) { |  | ||||||
|         if (token_info[key]) { |  | ||||||
|           search[key] = token_info[key]; |  | ||||||
|         } |  | ||||||
|       }); |  | ||||||
|       return store.IssuerOauth3OrgGrants.find(search).then(function (grants) { |  | ||||||
|         if (!grants.length) { |  | ||||||
|           throw new OpErr("'"+token_info.azp+"' not given any grants from '"+(token_info.sub || token_info.azpSub)+"'"); |  | ||||||
|         } |  | ||||||
|         if (grants.length > 1) { |  | ||||||
|           throw new Error("unexpected resource collision: too many relevant grants"); |  | ||||||
|         } |  | ||||||
|         var grant = grants[0]; |  | ||||||
|         Object.keys(grant).forEach(function (key) { |  | ||||||
|           token_info[key] = grant[key]; |  | ||||||
|         }); |  | ||||||
|         return token_info; |  | ||||||
|       }); |  | ||||||
|     }).then(function (token_info) { |  | ||||||
|       return Accounts.getPrivKey(store, req.experienceId).then(function (jwk) { |  | ||||||
|         var pem = require('jwk-to-pem')(jwk, { private: true }); |  | ||||||
|         var payload =  { |  | ||||||
|           // standard
 |  | ||||||
|           iss: token_info.iss,    // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.1
 |  | ||||||
|           aud: token_info.aud,    // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.3
 |  | ||||||
|           azp: token_info.azp, |  | ||||||
|           sub: token_info.azpSub, // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.2
 |  | ||||||
|           // extended
 |  | ||||||
|           scp: token_info.scope, |  | ||||||
|         }; |  | ||||||
|         var opts = { |  | ||||||
|           algorithm: jwk.alg, |  | ||||||
|           header: { |  | ||||||
|             kid: jwk.kid |  | ||||||
|           } |  | ||||||
|         }; |  | ||||||
|         var accessOpts  = {}; |  | ||||||
|         // We set `expiresIn` like this to make it possible to send `null` and `exp` to have
 |  | ||||||
|         // no expiration while still having a default of 1 day.
 |  | ||||||
|         if (req.body.hasOwnProperty('exp')) { |  | ||||||
|           accessOpts.expiresIn = timespan(req.body.exp, token_info.exp); |  | ||||||
|         } else { |  | ||||||
|           accessOpts.expiresIn = timespan('1d', token_info.exp); |  | ||||||
|         } |  | ||||||
|         var refreshOpts = {}; |  | ||||||
|         refreshOpts.expiresIn = timespan(req.body.refresh_exp, token_info.exp); |  | ||||||
| 
 |  | ||||||
|         var jwt = require('jsonwebtoken'); |  | ||||||
|         var result = {}; |  | ||||||
|         result.scope = token_info.scope; |  | ||||||
|         result.access_token = jwt.sign(payload, pem, Object.assign(accessOpts, opts)); |  | ||||||
|         if (req.body.refresh_token) { |  | ||||||
|           if (token_info.refresh_token) { |  | ||||||
|             result.refresh_token = token_info.refresh_token; |  | ||||||
|           } else { |  | ||||||
|             result.refresh_token = jwt.sign(payload, pem, Object.assign(refreshOpts, opts)); |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|         return result; |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     app.handlePromise(req, res, promise, '[issuer@oauth3.org] create tokens'); |  | ||||||
|   }; |  | ||||||
|   Accounts.restful.createToken.password = function (req) { |  | ||||||
|     var params = req.body; |  | ||||||
|     if (!params || !params.username) { |  | ||||||
|       return PromiseA.reject(PromiseA.OperationalError("must provide the email address as 'username' in the body")); |  | ||||||
|     } |  | ||||||
|     if ((params.username_type && 'email' !== params.username_type) || !/@/.test(params.username)) { |  | ||||||
|       return PromiseA.reject(PromiseA.OperationalError("only email one-time login codes are supported at this time")); |  | ||||||
|     } |  | ||||||
|     params.username_type = 'email'; |  | ||||||
|     if (!params.password) { |  | ||||||
|       return PromiseA.reject(new OpErr("request missing 'password'")); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     var codeId = crypto.createHash('sha256').update(params.username_type+':'+params.username).digest('base64'); |  | ||||||
|     codeId = makeB64UrlSafe(codeId); |  | ||||||
|     return req.getSiteStore().then(function (store) { |  | ||||||
|       return Accounts.validateOtp(store.IssuerOauth3OrgCodes, codeId, params.password) |  | ||||||
|       .then(function () { |  | ||||||
|         return Accounts.getOrCreate(store, params.username); |  | ||||||
|       }).then(function (account) { |  | ||||||
|         return { |  | ||||||
|           sub: account.accountId, |  | ||||||
|           aud: req.params.aud || req.body.aud || req.experienceId, |  | ||||||
|           azp: req.params.azp || req.body.azp || req.experienceId, |  | ||||||
|         }; |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
|   Accounts.restful.createToken.issuerToken = function (req) { |  | ||||||
|     return checkIsserToken(req, req.params.sub || req.body.sub).then(function (sub) { |  | ||||||
|       return { |  | ||||||
|         sub: sub, |  | ||||||
|         aud: req.params.aud || req.body.aud, |  | ||||||
|         azp: req.params.azp || req.body.azp, |  | ||||||
|         exp: req.oauth3.token.exp, |  | ||||||
|       }; |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
|   Accounts.restful.createToken.refreshToken = function (req) { |  | ||||||
|     return PromiseA.resolve().then(function () { |  | ||||||
|       if (!req.body.refresh_token) { |  | ||||||
|         throw new OpErr("missing refresh token"); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       return req.oauth3.verifyAsync(req.body.refresh_token).then(function (token) { |  | ||||||
|         return { |  | ||||||
|           sub: token.sub, |  | ||||||
|           aud: token.aud, |  | ||||||
|           azp: token.azp, |  | ||||||
|           exp: token.exp, |  | ||||||
|           refresh_token: req.body.refresh_token, |  | ||||||
|         }; |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|   app.get(   '/jwks/:sub/:kid.json', Jwks.restful.get); |   app.get(   '/jwks/:sub/:kid.json', Jwks.restful.get); | ||||||
|   app.get(   '/jwks/:sub/:kid',      Jwks.restful.get); |   app.get(   '/jwks/:sub/:kid',      Jwks.restful.get); | ||||||
|   // Everything but getting keys is only for the issuer
 |   // Everything but getting keys is only for the issuer
 | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user