├── images └── oz.png ├── .travis.yml ├── .gitignore ├── lib ├── index.js ├── scope.js ├── server.js ├── client.js ├── endpoints.js └── ticket.js ├── package.json ├── LICENSE ├── test ├── scope.js ├── index.js ├── server.js ├── client.js ├── ticket.js └── endpoints.js └── README.md /images/oz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkh44/oz/master/images/oz.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 4.0 5 | - 4 6 | - 5 7 | 8 | sudo: false 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | npm-debug.log 4 | dump.rdb 5 | node_modules 6 | results.tap 7 | results.xml 8 | npm-shrinkwrap.json 9 | config.json 10 | .DS_Store 11 | */.DS_Store 12 | */*/.DS_Store 13 | ._* 14 | */._* 15 | */*/._* 16 | coverage.* 17 | lib-cov 18 | 19 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Export sub-modules 4 | 5 | exports.client = require('./client'); 6 | exports.endpoints = require('./endpoints'); 7 | exports.hawk = require('hawk'); 8 | exports.scope = require('./scope'); 9 | exports.server = require('./server'); 10 | exports.ticket = require('./ticket'); 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oz", 3 | "description": "Web Authorization Protocol", 4 | "version": "4.0.0", 5 | "repository": "git://github.com/hueniverse/oz", 6 | "main": "lib/index.js", 7 | "keywords": [ 8 | "http", 9 | "authorization", 10 | "oz", 11 | "hawk" 12 | ], 13 | "engines": { 14 | "node": ">=4.x.x" 15 | }, 16 | "dependencies": { 17 | "boom": "3.x.x", 18 | "cryptiles": "3.x.x", 19 | "hawk": "^4.1.x", 20 | "hoek": "3.x.x", 21 | "iron": "4.x.x", 22 | "joi": "7.x.x", 23 | "wreck": "7.x.x" 24 | }, 25 | "devDependencies": { 26 | "code": "2.x.x", 27 | "lab": "8.x.x" 28 | }, 29 | "scripts": { 30 | "test": "lab -a code -t 100 -L", 31 | "test-cov-html": "lab -a code -r html -o coverage.html" 32 | }, 33 | "license": "BSD-3-Clause" 34 | } 35 | -------------------------------------------------------------------------------- /lib/scope.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Boom = require('boom'); 6 | const Hoek = require('hoek'); 7 | 8 | 9 | // Declare internals 10 | 11 | const internals = {}; 12 | 13 | 14 | // Ensure scope is an array of unique strings 15 | 16 | exports.validate = function (scope) { 17 | 18 | if (!scope) { 19 | return Boom.internal('null scope'); 20 | } 21 | 22 | if (scope instanceof Array === false) { 23 | return Boom.internal('scope not instance of Array'); 24 | } 25 | 26 | const hash = {}; 27 | for (let i = 0; i < scope.length; ++i) { 28 | if (!scope[i]) { 29 | return Boom.badRequest('scope includes null or empty string value'); 30 | } 31 | 32 | if (typeof scope[i] !== 'string') { 33 | return Boom.badRequest('scope item is not a string'); 34 | } 35 | 36 | if (hash[scope[i]]) { 37 | return Boom.badRequest('scope includes duplicated item'); 38 | } 39 | 40 | hash[scope[i]] = true; 41 | } 42 | 43 | return null; 44 | }; 45 | 46 | 47 | // Check is one scope is a subset of another 48 | 49 | exports.isSubset = function (scope, subset) { 50 | 51 | if (!scope) { 52 | return false; 53 | } 54 | 55 | if (scope.length < subset.length) { 56 | return false; 57 | } 58 | 59 | const common = Hoek.intersect(scope, subset); 60 | return common.length === subset.length; 61 | }; 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2016, Eran Hammer and other contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * The names of any contributors may not be used to endorse or promote 12 | products derived from this software without specific prior written 13 | permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | * * * 27 | 28 | The complete list of contributors can be found at: https://github.com/hueniverse/oz/graphs/contributors 29 | Portions of this project were initially based on the Yahoo! Inc. Postmile project, 30 | published at https://github.com/yahoo/postmile. 31 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Hoek = require('hoek'); 6 | const Hawk = require('hawk'); 7 | const Ticket = require('./ticket'); 8 | 9 | 10 | // Declare internals 11 | 12 | const internals = {}; 13 | 14 | 15 | // Validate an incoming request 16 | 17 | exports.authenticate = function (req, encryptionPassword, options, callback) { 18 | 19 | return exports._authenticate(req, encryptionPassword, true, options, callback); 20 | }; 21 | 22 | 23 | exports._authenticate = function (req, encryptionPassword, checkExpiration, options, callback) { 24 | 25 | Hoek.assert(encryptionPassword, 'Invalid encryption password'); 26 | Hoek.assert(options, 'Invalid options object'); 27 | 28 | // Hawk credentials lookup method 29 | 30 | const credentialsFunc = function (id, credsCallback) { 31 | 32 | // Parse ticket id 33 | 34 | Ticket.parse(id, encryptionPassword, options.ticket || {}, (err, ticket) => { 35 | 36 | if (err) { 37 | return credsCallback(err); 38 | } 39 | 40 | // Check expiration 41 | 42 | if (checkExpiration && 43 | ticket.exp <= Hawk.utils.now()) { 44 | 45 | const error = Hawk.utils.unauthorized('Expired ticket'); 46 | error.output.payload.expired = true; 47 | return credsCallback(error); 48 | } 49 | 50 | return credsCallback(null, ticket); 51 | }); 52 | }; 53 | 54 | // Hawk authentication 55 | 56 | Hawk.server.authenticate(req, credentialsFunc, options.hawk || {}, (err, credentials, artifacts) => { 57 | 58 | if (err) { 59 | return callback(err); 60 | } 61 | 62 | // Check application 63 | 64 | if (credentials.app !== artifacts.app) { 65 | return callback(Hawk.utils.unauthorized('Mismatching application id')); 66 | } 67 | 68 | if ((credentials.dlg || artifacts.dlg) && 69 | credentials.dlg !== artifacts.dlg) { 70 | 71 | return callback(Hawk.utils.unauthorized('Mismatching delegated application id')); 72 | } 73 | 74 | // Return result 75 | 76 | return callback(null, credentials, artifacts); 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /test/scope.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Code = require('code'); 6 | const Lab = require('lab'); 7 | const Oz = require('../lib'); 8 | 9 | 10 | // Declare internals 11 | 12 | const internals = {}; 13 | 14 | 15 | // Test shortcuts 16 | 17 | const lab = exports.lab = Lab.script(); 18 | const describe = lab.experiment; 19 | const it = lab.test; 20 | const expect = Code.expect; 21 | 22 | 23 | describe('Scope', () => { 24 | 25 | describe('validate()', () => { 26 | 27 | it('should return null for valid scope', (done) => { 28 | 29 | const scope = ['a', 'b', 'c']; 30 | const err = Oz.scope.validate(scope); 31 | expect(err).to.equal(null); 32 | done(); 33 | }); 34 | 35 | it('should return error when scope is null', (done) => { 36 | 37 | const err = Oz.scope.validate(null); 38 | expect(err).to.exist(); 39 | done(); 40 | }); 41 | 42 | it('should return error when scope is not an array', (done) => { 43 | 44 | const err = Oz.scope.validate({}); 45 | expect(err).to.exist(); 46 | done(); 47 | }); 48 | 49 | it('should return error when scope contains non-string values', (done) => { 50 | 51 | const scope = ['a', 'b', 1]; 52 | const err = Oz.scope.validate(scope); 53 | expect(err).to.exist(); 54 | done(); 55 | }); 56 | 57 | it('should return error when scope contains duplicates', (done) => { 58 | 59 | const scope = ['a', 'b', 'b']; 60 | const err = Oz.scope.validate(scope); 61 | expect(err).to.exist(); 62 | done(); 63 | }); 64 | 65 | it('should return error when scope contains empty strings', (done) => { 66 | 67 | const scope = ['a', 'b', '']; 68 | const err = Oz.scope.validate(scope); 69 | expect(err).to.exist(); 70 | done(); 71 | }); 72 | }); 73 | 74 | describe('isSubset()', () => { 75 | 76 | it('should return true when scope is a subset', (done) => { 77 | 78 | const scope = ['a', 'b', 'c']; 79 | const subset = ['a', 'c']; 80 | const isSubset = Oz.scope.isSubset(scope, subset); 81 | expect(isSubset).to.equal(true); 82 | done(); 83 | }); 84 | 85 | it('should return false when scope is not a subset', (done) => { 86 | 87 | const scope = ['a']; 88 | const subset = ['a', 'c']; 89 | const isSubset = Oz.scope.isSubset(scope, subset); 90 | expect(isSubset).to.equal(false); 91 | done(); 92 | }); 93 | 94 | it('should return false when scope is not a subset but equal length', (done) => { 95 | 96 | const scope = ['a', 'b']; 97 | const subset = ['a', 'c']; 98 | const isSubset = Oz.scope.isSubset(scope, subset); 99 | expect(isSubset).to.equal(false); 100 | done(); 101 | }); 102 | 103 | it('should return false when scope is not a subset due to duplicates', (done) => { 104 | 105 | const scope = ['a', 'c', 'c', 'd']; 106 | const subset = ['a', 'c', 'c']; 107 | const isSubset = Oz.scope.isSubset(scope, subset); 108 | expect(isSubset).to.equal(false); 109 | done(); 110 | }); 111 | }); 112 | }); 113 | 114 | 115 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Boom = require('boom'); 6 | const Hawk = require('hawk'); 7 | const Hoek = require('hoek'); 8 | const Wreck = require('wreck'); 9 | 10 | 11 | // Declare internals 12 | 13 | const internals = { 14 | defaults: { 15 | endpoints: { 16 | app: '/oz/app', 17 | reissue: '/oz/reissue' 18 | } 19 | } 20 | }; 21 | 22 | 23 | // Generate header 24 | 25 | exports.header = function (uri, method, ticket, options) { 26 | 27 | const settings = Hoek.shallow(options || {}); 28 | settings.credentials = ticket; 29 | settings.app = ticket.app; 30 | settings.dlg = ticket.dlg; 31 | 32 | return Hawk.client.header(uri, method, settings); 33 | }; 34 | 35 | 36 | exports.Connection = internals.Connection = function (options) { 37 | 38 | this.settings = Hoek.applyToDefaults(internals.defaults, options); 39 | this._appTicket = null; 40 | }; 41 | 42 | 43 | internals.Connection.prototype.request = function (path, ticket, options, callback) { 44 | 45 | const method = options.method || 'GET'; 46 | this._request(method, path, options.payload, ticket, (err, result, code) => { 47 | 48 | if (err) { 49 | return callback(err); 50 | } 51 | 52 | if (code !== 401 || 53 | !result || 54 | !result.expired) { 55 | 56 | return callback(null, result, code, ticket); 57 | } 58 | 59 | // Try to reissue ticket 60 | 61 | this.reissue(ticket, (refreshError, reissued) => { 62 | 63 | if (refreshError) { 64 | return callback(err); // Pass original request error 65 | } 66 | 67 | // Try resource again and pass back the ticket reissued (when not app) 68 | 69 | this._request(method, path, options.payload, reissued, (err, result2, code2) => { 70 | 71 | return callback(err, result2, code2, reissued); 72 | }); 73 | }); 74 | }); 75 | }; 76 | 77 | 78 | internals.Connection.prototype.app = function (path, options, callback) { 79 | 80 | const finalize = (err, result, code, ticket) => { 81 | 82 | if (err) { 83 | return callback(err); 84 | } 85 | 86 | this._appTicket = ticket; // In case ticket was refreshed 87 | return callback(null, result, code, ticket); 88 | }; 89 | 90 | if (this._appTicket) { 91 | return this.request(path, this._appTicket, options, finalize); 92 | } 93 | 94 | this._requestAppTicket((err) => { 95 | 96 | if (err) { 97 | return finalize(err); 98 | } 99 | 100 | return this.request(path, this._appTicket, options, finalize); 101 | }); 102 | }; 103 | 104 | 105 | internals.Connection.prototype.reissue = function (ticket, callback) { 106 | 107 | this._request('POST', this.settings.endpoints.reissue, null, ticket, (err, result, code) => { 108 | 109 | if (err) { 110 | return callback(err); 111 | } 112 | 113 | if (code !== 200) { 114 | return callback(Boom.internal(result.message)); 115 | } 116 | 117 | return callback(null, result); 118 | }); 119 | }; 120 | 121 | 122 | internals.Connection.prototype._request = function (method, path, payload, ticket, callback) { 123 | 124 | const body = (payload !== null ? JSON.stringify(payload) : null); 125 | const uri = this.settings.uri + path; 126 | const headers = {}; 127 | 128 | if (typeof payload === 'object') { 129 | headers['content-type'] = 'application/json'; 130 | } 131 | 132 | const header = exports.header(uri, method, ticket); 133 | headers.Authorization = header.field; 134 | 135 | Wreck.request(method, uri, { headers: headers, payload: body }, (err, response) => { 136 | 137 | if (err) { 138 | return callback(err); 139 | } 140 | 141 | Wreck.read(response, { json: true }, (err, result) => { 142 | 143 | if (err) { 144 | return callback(err); 145 | } 146 | 147 | Hawk.client.authenticate(response, ticket, header.artifacts, {}, (err, attributes) => { 148 | 149 | return callback(err, result, response.statusCode); 150 | }); 151 | }); 152 | }); 153 | }; 154 | 155 | 156 | internals.Connection.prototype._requestAppTicket = function (callback) { 157 | 158 | const uri = this.settings.uri + this.settings.endpoints.app; 159 | const header = exports.header(uri, 'POST', this.settings.credentials); 160 | Wreck.request('POST', uri, { headers: { Authorization: header.field } }, (err, response) => { 161 | 162 | if (err) { 163 | return callback(err); 164 | } 165 | 166 | Wreck.read(response, { json: true }, (err, result) => { 167 | 168 | if (err) { 169 | return callback(err); 170 | } 171 | 172 | if (response.statusCode !== 200) { 173 | return callback(Boom.internal('Client registration failed with unexpected response', { code: response.statusCode, payload: result })); 174 | } 175 | 176 | this._appTicket = result; 177 | return callback(); 178 | }); 179 | }); 180 | }; 181 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Code = require('code'); 6 | const Lab = require('lab'); 7 | const Oz = require('../lib'); 8 | 9 | 10 | // Declare internals 11 | 12 | const internals = {}; 13 | 14 | 15 | // Test shortcuts 16 | 17 | const lab = exports.lab = Lab.script(); 18 | const describe = lab.experiment; 19 | const it = lab.test; 20 | const expect = Code.expect; 21 | 22 | 23 | describe('Oz', () => { 24 | 25 | it('runs a full authorization flow', (done) => { 26 | 27 | const encryptionPassword = 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough'; 28 | 29 | const apps = { 30 | social: { 31 | id: 'social', 32 | scope: ['a', 'b', 'c'], 33 | key: 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn', 34 | algorithm: 'sha256', 35 | delegate: true 36 | }, 37 | network: { 38 | id: 'network', 39 | scope: ['b', 'x'], 40 | key: 'witf745itwn7ey4otnw7eyi4t7syeir7bytise7rbyi', 41 | algorithm: 'sha256' 42 | } 43 | }; 44 | 45 | // The app requests an app ticket using Oz.hawk authentication 46 | 47 | let req = { 48 | method: 'POST', 49 | url: '/oz/app', 50 | headers: { 51 | host: 'example.com', 52 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).field 53 | } 54 | }; 55 | 56 | const options = { 57 | encryptionPassword: encryptionPassword, 58 | loadAppFunc: function (id, callback) { 59 | 60 | callback(null, apps[id]); 61 | } 62 | }; 63 | 64 | Oz.endpoints.app(req, null, options, (err, appTicket) => { 65 | 66 | expect(err).to.not.exist(); 67 | 68 | // The app refreshes its own ticket 69 | 70 | req = { 71 | method: 'POST', 72 | url: '/oz/reissue', 73 | headers: { 74 | host: 'example.com', 75 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).field 76 | } 77 | }; 78 | 79 | Oz.endpoints.reissue(req, {}, options, (err, reAppTicket) => { 80 | 81 | expect(err).to.not.exist(); 82 | 83 | // The user is redirected to the server, logs in, and grant app access, resulting in an rsvp 84 | 85 | const grant = { 86 | id: 'a1b2c3d4e5f6g7h8i9j0', 87 | app: reAppTicket.app, 88 | user: 'john', 89 | exp: Oz.hawk.utils.now() + 60000 90 | }; 91 | 92 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 93 | 94 | expect(err).to.not.exist(); 95 | 96 | // After granting app access, the user returns to the app with the rsvp 97 | 98 | options.loadGrantFunc = function (id, callback) { 99 | 100 | const ext = { 101 | public: 'everybody knows', 102 | private: 'the the dice are loaded' 103 | }; 104 | 105 | callback(null, grant, ext); 106 | }; 107 | 108 | // The app exchanges the rsvp for a ticket 109 | 110 | let payload = { rsvp: rsvp }; 111 | 112 | req = { 113 | method: 'POST', 114 | url: '/oz/rsvp', 115 | headers: { 116 | host: 'example.com', 117 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', reAppTicket).field 118 | } 119 | }; 120 | 121 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 122 | 123 | expect(err).to.not.exist(); 124 | 125 | // The app reissues the ticket with delegation to another app 126 | 127 | payload = { 128 | issueTo: apps.network.id, 129 | scope: ['a'] 130 | }; 131 | 132 | req = { 133 | method: 'POST', 134 | url: '/oz/reissue', 135 | headers: { 136 | host: 'example.com', 137 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).field 138 | } 139 | }; 140 | 141 | Oz.endpoints.reissue(req, payload, options, (err, delegatedTicket) => { 142 | 143 | expect(err).to.not.exist(); 144 | 145 | // The other app reissues their ticket 146 | 147 | req = { 148 | method: 'POST', 149 | url: '/oz/reissue', 150 | headers: { 151 | host: 'example.com', 152 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', delegatedTicket).field 153 | } 154 | }; 155 | 156 | Oz.endpoints.reissue(req, {}, options, (err, reissuedDelegatedTicket) => { 157 | 158 | expect(err).to.not.exist(); 159 | done(); 160 | }); 161 | }); 162 | }); 163 | }); 164 | }); 165 | }); 166 | }); 167 | }); 168 | 169 | 170 | -------------------------------------------------------------------------------- /lib/endpoints.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Boom = require('boom'); 6 | const Joi = require('joi'); 7 | const Hoek = require('hoek'); 8 | const Hawk = require('hawk'); 9 | const Ticket = require('./ticket'); 10 | const Server = require('./server'); 11 | 12 | 13 | // Declare internals 14 | 15 | const internals = { 16 | schema: {} 17 | }; 18 | 19 | 20 | /* 21 | const options = { 22 | encryptionPassword: 'f84rf84r3hjdf8hw38hr', 23 | hawk: {}, 24 | ticket: {}, 25 | 26 | loadAppFunc: function (id, callback) { callback(err, app); }, 27 | loadGrantFunc: function (id, callback) { callback(err, grant, ext); } 28 | }; 29 | */ 30 | 31 | // Request an application ticket using Hawk authentication 32 | 33 | exports.app = function (req, payload, options, callback) { 34 | 35 | Hawk.server.authenticate(req, options.loadAppFunc, options.hawk || {}, (err, credentials, artifacts) => { 36 | 37 | if (err) { 38 | return callback(err); 39 | } 40 | 41 | // Issue application ticket 42 | 43 | Ticket.issue(credentials, null, options.encryptionPassword, options.ticket || {}, callback); 44 | }); 45 | }; 46 | 47 | 48 | // Request a ticket reissue using the authenticating ticket 49 | 50 | internals.schema.reissue = Joi.object({ 51 | issueTo: Joi.string(), 52 | scope: Joi.array().items(Joi.string()) 53 | }); 54 | 55 | 56 | exports.reissue = function (req, payload, options, callback) { 57 | 58 | payload = payload || {}; 59 | 60 | const validate = () => { 61 | 62 | const error = Joi.validate(payload, internals.schema.reissue).error; 63 | if (error) { 64 | return callback(Boom.badRequest(error.message)); 65 | } 66 | 67 | Server._authenticate(req, options.encryptionPassword, false, options, (err, ticket, artifacts) => { 68 | 69 | if (err) { 70 | return callback(err); 71 | } 72 | 73 | // Load ticket 74 | 75 | options.loadAppFunc(ticket.app, (err, app) => { 76 | 77 | if (err) { 78 | return callback(err); 79 | } 80 | 81 | if (!app) { 82 | return callback(Hawk.utils.unauthorized('Invalid application')); 83 | } 84 | 85 | if (payload.issueTo && 86 | !app.delegate) { 87 | 88 | return callback(Boom.forbidden('Application has no delegation rights')); 89 | } 90 | 91 | // Application ticket 92 | 93 | if (!ticket.grant) { 94 | return reissue(ticket, app); 95 | } 96 | 97 | // User ticket 98 | 99 | options.loadGrantFunc(ticket.grant, (err, grant, ext) => { 100 | 101 | if (err) { 102 | return callback(err); 103 | } 104 | 105 | if (!grant || 106 | (grant.app !== ticket.app && grant.app !== ticket.dlg) || 107 | grant.user !== ticket.user || 108 | !grant.exp || 109 | grant.exp <= Hawk.utils.now()) { 110 | 111 | return callback(Hawk.utils.unauthorized('Invalid grant')); 112 | } 113 | 114 | return reissue(ticket, app, grant, ext); 115 | }); 116 | }); 117 | }); 118 | }; 119 | 120 | const reissue = (ticket, app, grant, ext) => { 121 | 122 | const ticketOptions = Hoek.shallow(options.ticket || {}); 123 | 124 | if (ext) { 125 | ticketOptions.ext = ext; 126 | } 127 | 128 | if (payload.issueTo) { 129 | ticketOptions.issueTo = payload.issueTo; 130 | } 131 | 132 | if (payload.scope) { 133 | ticketOptions.scope = payload.scope; 134 | } 135 | 136 | Ticket.reissue(ticket, grant, options.encryptionPassword, ticketOptions, callback); 137 | }; 138 | 139 | validate(); 140 | }; 141 | 142 | 143 | internals.schema.rsvp = Joi.object({ 144 | rsvp: Joi.string().required() 145 | }); 146 | 147 | 148 | exports.rsvp = function (req, payload, options, callback) { 149 | 150 | if (!payload) { 151 | return callback(Boom.badRequest('Missing required payload')); 152 | } 153 | 154 | const error = Joi.validate(payload, internals.schema.rsvp).error; 155 | if (error) { 156 | return callback(Boom.badRequest(error.message)); 157 | } 158 | 159 | Server.authenticate(req, options.encryptionPassword, options, (err, ticket, artifacts) => { 160 | 161 | if (err) { 162 | return callback(err); 163 | } 164 | 165 | if (ticket.user) { 166 | return callback(Hawk.utils.unauthorized('User ticket cannot be used on an application endpoint')); 167 | } 168 | 169 | Ticket.parse(payload.rsvp, options.encryptionPassword, options.ticket || {}, (err, envelope) => { 170 | 171 | if (err) { 172 | return callback(err); 173 | } 174 | 175 | if (envelope.app !== ticket.app) { 176 | return callback(Boom.forbidden('Mismatching ticket and rsvp apps')); 177 | } 178 | 179 | const now = Hawk.utils.now(); 180 | 181 | if (envelope.exp <= now) { 182 | return callback(Boom.forbidden('Expired rsvp')); 183 | } 184 | 185 | options.loadGrantFunc(envelope.grant, (err, grant, ext) => { 186 | 187 | if (err) { 188 | return callback(err); 189 | } 190 | 191 | if (!grant || 192 | grant.app !== ticket.app || 193 | !grant.exp || 194 | grant.exp <= now) { 195 | 196 | return callback(Boom.forbidden('Invalid grant')); 197 | } 198 | 199 | options.loadAppFunc(grant.app, (err, app) => { 200 | 201 | if (err) { 202 | return callback(err); 203 | } 204 | 205 | if (!app) { 206 | return callback(Boom.forbidden('Invalid application')); 207 | } 208 | 209 | let ticketOptions = options.ticket || {}; 210 | if (ext) { 211 | ticketOptions = Hoek.shallow(ticketOptions); 212 | ticketOptions.ext = ext; 213 | } 214 | 215 | Ticket.issue(app, grant, options.encryptionPassword, ticketOptions, callback); 216 | }); 217 | }); 218 | }); 219 | }); 220 | }; 221 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Code = require('code'); 6 | const Lab = require('lab'); 7 | const Oz = require('../lib'); 8 | 9 | 10 | // Declare internals 11 | 12 | const internals = {}; 13 | 14 | 15 | // Test shortcuts 16 | 17 | const lab = exports.lab = Lab.script(); 18 | const describe = lab.experiment; 19 | const it = lab.test; 20 | const expect = Code.expect; 21 | 22 | 23 | describe('Server', () => { 24 | 25 | describe('authenticate()', () => { 26 | 27 | it('throws an error on missing password', (done) => { 28 | 29 | expect(() => { 30 | 31 | Oz.server.authenticate(null, null, {}, () => { }); 32 | }).to.throw('Invalid encryption password'); 33 | done(); 34 | }); 35 | 36 | const encryptionPassword = 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough'; 37 | 38 | const app = { 39 | id: '123' 40 | }; 41 | 42 | it('authenticates a request', (done) => { 43 | 44 | const grant = { 45 | id: 's81u29n1812', 46 | user: '456', 47 | exp: Oz.hawk.utils.now() + 5000, 48 | scope: ['a', 'b'] 49 | }; 50 | 51 | Oz.ticket.issue(app, grant, encryptionPassword, {}, (err, envelope) => { 52 | 53 | expect(err).to.not.exist(); 54 | 55 | const req = { 56 | method: 'POST', 57 | url: '/oz/rsvp', 58 | headers: { 59 | host: 'example.com', 60 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).field 61 | } 62 | }; 63 | 64 | Oz.server.authenticate(req, encryptionPassword, {}, (err, credentials, artifacts) => { 65 | 66 | expect(err).to.not.exist(); 67 | done(); 68 | }); 69 | }); 70 | }); 71 | 72 | it('authenticates a request (hawk options)', (done) => { 73 | 74 | const grant = { 75 | id: 's81u29n1812', 76 | user: '456', 77 | exp: Oz.hawk.utils.now() + 5000, 78 | scope: ['a', 'b'] 79 | }; 80 | 81 | Oz.ticket.issue(app, grant, encryptionPassword, {}, (err, envelope) => { 82 | 83 | expect(err).to.not.exist(); 84 | 85 | const req = { 86 | method: 'POST', 87 | url: '/oz/rsvp', 88 | headers: { 89 | hostx1: 'example.com', 90 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).field 91 | } 92 | }; 93 | 94 | Oz.server.authenticate(req, encryptionPassword, { hawk: { hostHeaderName: 'hostx1' } }, (err, credentials, artifacts) => { 95 | 96 | expect(err).to.not.exist(); 97 | done(); 98 | }); 99 | }); 100 | }); 101 | 102 | it('fails to authenticate a request with bad password', (done) => { 103 | 104 | const grant = { 105 | id: 's81u29n1812', 106 | user: '456', 107 | exp: Oz.hawk.utils.now() + 5000, 108 | scope: ['a', 'b'] 109 | }; 110 | 111 | Oz.ticket.issue(app, grant, encryptionPassword, {}, (err, envelope) => { 112 | 113 | expect(err).to.not.exist(); 114 | 115 | const req = { 116 | method: 'POST', 117 | url: '/oz/rsvp', 118 | headers: { 119 | host: 'example.com', 120 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).field 121 | } 122 | }; 123 | 124 | Oz.server.authenticate(req, 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough_x', {}, (err, credentials, artifacts) => { 125 | 126 | expect(err).to.exist(); 127 | expect(err.message).to.equal('Bad hmac value'); 128 | done(); 129 | }); 130 | }); 131 | }); 132 | 133 | it('fails to authenticate a request with expired ticket', (done) => { 134 | 135 | const grant = { 136 | id: 's81u29n1812', 137 | user: '456', 138 | exp: Oz.hawk.utils.now() - 5000, 139 | scope: ['a', 'b'] 140 | }; 141 | 142 | Oz.ticket.issue(app, grant, encryptionPassword, {}, (err, envelope) => { 143 | 144 | expect(err).to.not.exist(); 145 | 146 | const req = { 147 | method: 'POST', 148 | url: '/oz/rsvp', 149 | headers: { 150 | host: 'example.com', 151 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).field 152 | } 153 | }; 154 | 155 | Oz.server.authenticate(req, encryptionPassword, {}, (err, credentials, artifacts) => { 156 | 157 | expect(err).to.exist(); 158 | expect(err.message).to.equal('Expired ticket'); 159 | expect(err.output.payload.expired).to.be.true(); 160 | done(); 161 | }); 162 | }); 163 | }); 164 | 165 | it('fails to authenticate a request with mismatching app id', (done) => { 166 | 167 | const grant = { 168 | id: 's81u29n1812', 169 | user: '456', 170 | exp: Oz.hawk.utils.now() + 5000, 171 | scope: ['a', 'b'] 172 | }; 173 | 174 | Oz.ticket.issue(app, grant, encryptionPassword, {}, (err, envelope) => { 175 | 176 | expect(err).to.not.exist(); 177 | 178 | envelope.app = '567'; 179 | const req = { 180 | method: 'POST', 181 | url: '/oz/rsvp', 182 | headers: { 183 | host: 'example.com', 184 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).field 185 | } 186 | }; 187 | 188 | Oz.server.authenticate(req, encryptionPassword, {}, (err, credentials, artifacts) => { 189 | 190 | expect(err).to.exist(); 191 | expect(err.message).to.equal('Mismatching application id'); 192 | done(); 193 | }); 194 | }); 195 | }); 196 | 197 | it('fails to authenticate a request with mismatching dlg id', (done) => { 198 | 199 | const grant = { 200 | id: 's81u29n1812', 201 | user: '456', 202 | exp: Oz.hawk.utils.now() + 5000, 203 | scope: ['a', 'b'] 204 | }; 205 | 206 | Oz.ticket.issue(app, grant, encryptionPassword, {}, (err, envelope) => { 207 | 208 | expect(err).to.not.exist(); 209 | 210 | envelope.dlg = '567'; 211 | const req = { 212 | method: 'POST', 213 | url: '/oz/rsvp', 214 | headers: { 215 | host: 'example.com', 216 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).field 217 | } 218 | }; 219 | 220 | Oz.server.authenticate(req, encryptionPassword, {}, (err, credentials, artifacts) => { 221 | 222 | expect(err).to.exist(); 223 | expect(err.message).to.equal('Mismatching delegated application id'); 224 | done(); 225 | }); 226 | }); 227 | }); 228 | }); 229 | }); 230 | -------------------------------------------------------------------------------- /lib/ticket.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Boom = require('boom'); 6 | const Cryptiles = require('cryptiles'); 7 | const Hawk = require('hawk'); 8 | const Hoek = require('hoek'); 9 | const Iron = require('iron'); 10 | const Scope = require('./scope'); 11 | 12 | 13 | // Declare internals 14 | 15 | const internals = {}; 16 | 17 | 18 | internals.defaults = { 19 | ticketTTL: 60 * 60 * 1000, // 1 hour 20 | rsvpTTL: 1 * 60 * 1000, // 1 minute 21 | keyBytes: 32, // Ticket secret size in bytes 22 | hmacAlgorithm: 'sha256' 23 | }; 24 | 25 | 26 | /* 27 | const app = { 28 | id: '123', // Application id 29 | scope: ['a', 'b'] // Application scope 30 | }; 31 | 32 | const grant = { 33 | id: 'd832d9283hd9823dh', // Persistent identifier used to issue additional tickets or revoke access 34 | user: '456', // User id 35 | exp: 1352535473414, // Grant expiration 36 | scope: ['b'] // Grant scope 37 | }; 38 | 39 | const options = { 40 | ttl: 60 * 1000, // 1 min 41 | delegate: false, // Ticket-specific delegation permission (default to true) 42 | ext: { // Server-specific extension data 43 | public: { // Included in the plain ticket 44 | tos: '0.0.1' 45 | }, 46 | private: { // Included in the encoded ticket 47 | x: 1 48 | } 49 | }, 50 | iron: {} // Override Iron defaults 51 | keyBytes: 32, // Hawk key length 52 | hmacAlgorithm: 'sha256' // Hawk algorithm 53 | }; 54 | */ 55 | 56 | exports.issue = function (app, grant, encryptionPassword, options, callback) { 57 | 58 | const fail = Hoek.nextTick(callback); 59 | 60 | if (!app || !app.id) { 61 | return fail(Boom.internal('Invalid application object')); 62 | } 63 | 64 | if (grant && (!grant.id || !grant.user || !grant.exp)) { 65 | return fail(Boom.internal('Invalid grant object')); 66 | } 67 | 68 | if (!encryptionPassword) { 69 | return fail(Boom.internal('Invalid encryption password')); 70 | } 71 | 72 | if (!options) { 73 | return fail(Boom.internal('Invalid options object')); 74 | } 75 | 76 | const scope = (grant && grant.scope) || app.scope || []; 77 | const error = Scope.validate(scope); 78 | if (error) { 79 | return fail(error); 80 | } 81 | 82 | if (grant && 83 | grant.scope && 84 | app.scope && 85 | !Scope.isSubset(app.scope, grant.scope)) { 86 | 87 | return fail(Boom.internal('Grant scope is not a subset of the application scope')); 88 | } 89 | 90 | // Construct ticket 91 | 92 | let exp = (Hawk.utils.now() + (options.ttl || internals.defaults.ticketTTL)); 93 | if (grant) { 94 | exp = Math.min(exp, grant.exp); 95 | } 96 | 97 | const ticket = { 98 | exp: exp, 99 | app: app.id, 100 | scope: scope 101 | }; 102 | 103 | if (grant) { 104 | ticket.grant = grant.id; 105 | ticket.user = grant.user; 106 | } 107 | 108 | if (options.delegate === false) { // Defaults to true 109 | ticket.delegate = false; 110 | } 111 | 112 | exports.generate(ticket, encryptionPassword, options, callback); 113 | }; 114 | 115 | 116 | // Reissue ticket 117 | 118 | /* 119 | const grant = { 120 | id: 'd832d9283hd9823dh', // Persistent identifier used to issue additional tickets or revoke access 121 | user: '456', // User id 122 | exp: 1352535473414, // Grant expiration 123 | scope: ['b'] // Grant scope 124 | }; 125 | 126 | const options = { 127 | ttl: 60 * 1000, // 1 min 128 | delegate: false, // Ticket-specific delegation permission (default to true) 129 | scope: ['b'], // Ticket scope (must be equal or lesser than parent) 130 | issueTo: '123', // Delegated to application id 131 | ext: { // Server-specific extension data 132 | public: { // Included in the plain ticket 133 | tos: '0.0.1' 134 | }, 135 | private: { // Included in the encoded ticket 136 | x: 1 137 | } 138 | }, 139 | iron: {} // Override Iron defaults 140 | keyBytes: 32, // Hawk key length 141 | hmacAlgorithm: 'sha256' // Hawk algorithm 142 | }; 143 | */ 144 | 145 | exports.reissue = function (parentTicket, grant, encryptionPassword, options, callback) { 146 | 147 | const fail = Hoek.nextTick(callback); 148 | 149 | if (!parentTicket) { 150 | return fail(Boom.internal('Invalid parent ticket object')); 151 | } 152 | 153 | if (!encryptionPassword) { 154 | return fail(Boom.internal('Invalid encryption password')); 155 | } 156 | 157 | if (!options) { 158 | return fail(Boom.internal('Invalid options object')); 159 | } 160 | 161 | if (parentTicket.scope) { 162 | const error = Scope.validate(parentTicket.scope); 163 | if (error) { 164 | return fail(error); 165 | } 166 | } 167 | 168 | if (options.scope) { 169 | const error = Scope.validate(options.scope); 170 | if (error) { 171 | return fail(error); 172 | } 173 | 174 | if (!Scope.isSubset(parentTicket.scope, options.scope)) { 175 | return fail(Boom.forbidden('New scope is not a subset of the parent ticket scope')); 176 | } 177 | } 178 | 179 | if (options.delegate && 180 | parentTicket.delegate === false) { 181 | 182 | return fail(Boom.forbidden('Cannot override ticket delegate restriction')); 183 | } 184 | 185 | if (options.issueTo) { 186 | if (parentTicket.dlg) { 187 | return fail(Boom.badRequest('Cannot re-delegate')); 188 | } 189 | 190 | if (parentTicket.delegate === false) { // Defaults to true 191 | return fail(Boom.forbidden('Ticket does not allow delegation')); 192 | } 193 | } 194 | 195 | if (grant && (!grant.id || !grant.user || !grant.exp)) { 196 | return fail(Boom.internal('Invalid grant object')); 197 | } 198 | 199 | if (grant || parentTicket.grant) { 200 | if (!grant || 201 | !parentTicket.grant || 202 | parentTicket.grant !== grant.id) { 203 | 204 | return fail(Boom.internal('Parent ticket grant does not match options.grant')); 205 | } 206 | } 207 | 208 | // Construct ticket 209 | 210 | let exp = (Hawk.utils.now() + (options.ttl || internals.defaults.ticketTTL)); 211 | if (grant) { 212 | exp = Math.min(exp, grant.exp); 213 | } 214 | 215 | const ticket = { 216 | exp: exp, 217 | app: options.issueTo || parentTicket.app, 218 | scope: options.scope || parentTicket.scope 219 | }; 220 | 221 | if (!options.ext && 222 | parentTicket.ext) { 223 | 224 | options = Hoek.shallow(options); 225 | options.ext = parentTicket.ext; 226 | } 227 | 228 | if (grant) { 229 | ticket.grant = grant.id; 230 | ticket.user = grant.user; 231 | } 232 | 233 | if (options.issueTo) { 234 | ticket.dlg = parentTicket.app; 235 | } 236 | else if (parentTicket.dlg) { 237 | ticket.dlg = parentTicket.dlg; 238 | } 239 | 240 | if (options.delegate === false || // Defaults to true 241 | parentTicket.delegate === false) { 242 | 243 | ticket.delegate = false; 244 | } 245 | 246 | exports.generate(ticket, encryptionPassword, options, callback); 247 | }; 248 | 249 | 250 | /* 251 | // The requesting application 252 | 253 | const app = { 254 | id: '123', // Application id 255 | }; 256 | 257 | // The resource owner 258 | 259 | const grant = { 260 | id: 'd832d9283hd9823dh' // Persistent identifier used to issue additional tickets or revoke access 261 | }; 262 | 263 | const options = { 264 | ttl: 1 * 60 * 10000, // Rsvp TTL 265 | iron: {} // Override Iron defaults 266 | }; 267 | */ 268 | 269 | exports.rsvp = function (app, grant, encryptionPassword, options, callback) { 270 | 271 | const fail = Hoek.nextTick(callback); 272 | 273 | if (!app || !app.id) { 274 | return fail(Boom.internal('Invalid application object')); 275 | } 276 | 277 | if (!grant || !grant.id) { 278 | return fail(Boom.internal('Invalid grant object')); 279 | } 280 | 281 | if (!encryptionPassword) { 282 | return fail(Boom.internal('Invalid encryption password')); 283 | } 284 | 285 | if (!options) { 286 | return fail(Boom.internal('Invalid options object')); 287 | } 288 | 289 | options.ttl = options.ttl || internals.defaults.rsvpTTL; 290 | 291 | // Construct envelope 292 | 293 | const envelope = { 294 | app: app.id, 295 | exp: Hawk.utils.now() + options.ttl, 296 | grant: grant.id 297 | }; 298 | 299 | // Stringify and encrypt 300 | 301 | Iron.seal(envelope, encryptionPassword, options.iron || Iron.defaults, (err, sealed) => { 302 | 303 | if (err) { 304 | return callback(err); 305 | } 306 | 307 | const rsvp = sealed; 308 | return callback(null, rsvp); 309 | }); 310 | }; 311 | 312 | 313 | /* 314 | const ticket = { 315 | 316 | // Inputs into generate() 317 | 318 | exp: time in msec 319 | app: app id ticket is issued to 320 | scope: ticket scope 321 | grant: grant id 322 | user: user id 323 | dlg: app id of the delegating party 324 | 325 | // Added by generate() 326 | 327 | key: ticket secret key (Hawk) 328 | algorithm: ticket hmac algorithm (Hawk) 329 | id: ticket key id (Hawk) 330 | ext: application data { public, private } 331 | }; 332 | 333 | const options = { 334 | iron: {}, // Override Iron defaults 335 | keyBytes: 32, // Hawk key length 336 | hmacAlgorithm: 'sha256' // Hawk algorithm 337 | }; 338 | */ 339 | 340 | exports.generate = function (ticket, encryptionPassword, options, callback) { 341 | 342 | const fail = Hoek.nextTick(callback); 343 | 344 | // Generate ticket secret 345 | 346 | const random = Cryptiles.randomString(options.keyBytes || internals.defaults.keyBytes); 347 | if (random instanceof Error) { 348 | return fail(random); 349 | } 350 | 351 | ticket.key = random; 352 | ticket.algorithm = options.hmacAlgorithm || internals.defaults.hmacAlgorithm; 353 | 354 | // Ext data 355 | 356 | if (options.ext) { 357 | ticket.ext = {}; 358 | 359 | // Explicit copy to avoid unintentional leaking of private data as public or changes to options object 360 | 361 | if (options.ext.public !== undefined) { 362 | ticket.ext.public = options.ext.public; 363 | } 364 | 365 | if (options.ext.private !== undefined) { 366 | ticket.ext.private = options.ext.private; 367 | } 368 | } 369 | 370 | // Seal ticket 371 | 372 | Iron.seal(ticket, encryptionPassword, options.iron || Iron.defaults, (err, sealed) => { 373 | 374 | if (err) { 375 | return callback(err); 376 | } 377 | 378 | ticket.id = sealed; 379 | 380 | // Hide private ext data 381 | 382 | if (ticket.ext) { 383 | if (ticket.ext.public !== undefined) { 384 | ticket.ext = ticket.ext.public; 385 | } 386 | else { 387 | delete ticket.ext; 388 | } 389 | } 390 | 391 | return callback(null, ticket); 392 | }); 393 | }; 394 | 395 | 396 | // Parse ticket id 397 | 398 | /* 399 | const options = { 400 | iron: {} // Override Iron defaults 401 | }; 402 | */ 403 | 404 | exports.parse = function (id, encryptionPassword, options, callback) { 405 | 406 | const fail = Hoek.nextTick(callback); 407 | 408 | if (!encryptionPassword) { 409 | return fail(Boom.internal('Invalid encryption password')); 410 | } 411 | 412 | if (!options) { 413 | return fail(Boom.internal('Invalid options object')); 414 | } 415 | 416 | Iron.unseal(id, encryptionPassword, options.iron || Iron.defaults, (err, object) => { 417 | 418 | if (err) { 419 | return callback(err); 420 | } 421 | 422 | const ticket = object; 423 | ticket.id = id; 424 | return callback(null, ticket); 425 | }); 426 | }; 427 | -------------------------------------------------------------------------------- /test/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Http = require('http'); 6 | const Code = require('code'); 7 | const Iron = require('iron'); 8 | const Lab = require('lab'); 9 | const Oz = require('..'); 10 | const Wreck = require('wreck'); 11 | 12 | 13 | // Declare internals 14 | 15 | const internals = {}; 16 | 17 | 18 | // Test shortcuts 19 | 20 | const lab = exports.lab = Lab.script(); 21 | const describe = lab.experiment; 22 | const it = lab.test; 23 | const expect = Code.expect; 24 | 25 | 26 | describe('Client', () => { 27 | 28 | describe('header()', () => { 29 | 30 | it('', (done) => { 31 | 32 | const app = { 33 | id: 'social', 34 | scope: ['a', 'b', 'c'], 35 | key: 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn', 36 | algorithm: 'sha256' 37 | }; 38 | 39 | const header = Oz.client.header('http://example.com/oz/app', 'POST', app, {}).field; 40 | expect(header).to.exist(); 41 | done(); 42 | }); 43 | }); 44 | 45 | describe('Connection', () => { 46 | 47 | it('obtains an application ticket and requests resource', (done) => { 48 | 49 | const mock = new internals.Mock(); 50 | mock.start((uri) => { 51 | 52 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 53 | connection.app('/', {}, (err, result1, code1, ticket1) => { 54 | 55 | expect(err).to.not.exist(); 56 | expect(result1).to.equal('GET /'); 57 | expect(code1).to.equal(200); 58 | expect(ticket1).to.equal(connection._appTicket); 59 | 60 | connection.request('/resource', ticket1, {}, (err, result2, code2, ticket2) => { 61 | 62 | expect(err).to.not.exist(); 63 | expect(result2).to.equal('GET /resource'); 64 | expect(code2).to.equal(200); 65 | expect(ticket2).to.equal(ticket1); 66 | 67 | connection.reissue(ticket2, (err, ticket3) => { 68 | 69 | expect(err).to.not.exist(); 70 | expect(ticket3).to.not.equal(ticket2); 71 | 72 | connection.request('/resource', ticket3, {}, (err, result4, code4, ticket4) => { 73 | 74 | expect(err).to.not.exist(); 75 | expect(result4).to.equal('GET /resource'); 76 | expect(code4).to.equal(200); 77 | expect(ticket4).to.equal(ticket3); 78 | 79 | mock.stop(done); 80 | }); 81 | }); 82 | }); 83 | }); 84 | }); 85 | }); 86 | 87 | describe('request()', () => { 88 | 89 | it('automatically refreshes ticket', (done) => { 90 | 91 | const mock = new internals.Mock({ ttl: 20 }); 92 | mock.start((uri) => { 93 | 94 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 95 | connection.app('/', {}, (err, result1, code1, ticket1) => { 96 | 97 | expect(err).to.not.exist(); 98 | expect(result1).to.equal('GET /'); 99 | expect(code1).to.equal(200); 100 | expect(ticket1).to.equal(connection._appTicket); 101 | 102 | setTimeout(() => { 103 | 104 | connection.request('/resource', ticket1, { method: 'POST' }, (err, result2, code2, ticket2) => { 105 | 106 | expect(err).to.not.exist(); 107 | expect(result2).to.equal('POST /resource'); 108 | expect(code2).to.equal(200); 109 | expect(ticket2).to.not.equal(ticket1); 110 | 111 | mock.stop(done); 112 | }); 113 | }, 30); 114 | }); 115 | }); 116 | }); 117 | 118 | it('errors on socket fail', { parallel: false }, (done) => { 119 | 120 | const mock = new internals.Mock(); 121 | mock.start((uri) => { 122 | 123 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 124 | connection.app('/', {}, (err, result1, code1, ticket1) => { 125 | 126 | expect(err).to.not.exist(); 127 | expect(result1).to.equal('GET /'); 128 | expect(code1).to.equal(200); 129 | expect(ticket1).to.equal(connection._appTicket); 130 | 131 | const orig = Wreck.request; 132 | Wreck.request = function (method, path, options, callback) { 133 | 134 | Wreck.request = orig; 135 | return callback(new Error('bad socket')); 136 | }; 137 | 138 | connection.request('/resource', ticket1, {}, (err, result2, code2, ticket2) => { 139 | 140 | expect(err).to.exist(); 141 | mock.stop(done); 142 | }); 143 | }); 144 | }); 145 | }); 146 | 147 | it('errors on reissue fail', { parallel: false }, (done) => { 148 | 149 | const mock = new internals.Mock({ ttl: 10 }); 150 | mock.start((uri) => { 151 | 152 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 153 | connection.app('/', {}, (err, result1, code1, ticket1) => { 154 | 155 | expect(err).to.not.exist(); 156 | expect(result1).to.equal('GET /'); 157 | expect(code1).to.equal(200); 158 | expect(ticket1).to.equal(connection._appTicket); 159 | 160 | setTimeout(() => { 161 | 162 | let count = 0; 163 | const orig = Wreck.request; 164 | Wreck.request = function (method, path, options, callback) { 165 | 166 | if (++count === 1) { 167 | return orig.apply(Wreck, arguments); 168 | } 169 | 170 | Wreck.request = orig; 171 | return callback(new Error('bad socket')); 172 | }; 173 | 174 | connection.request('/resource', ticket1, { method: 'POST' }, (err, result2, code2, ticket2) => { 175 | 176 | expect(err).to.not.exist(); 177 | mock.stop(done); 178 | }); 179 | }, 11); 180 | }); 181 | }); 182 | }); 183 | 184 | it('does not reissue a 401 without payload', (done) => { 185 | 186 | const mock = new internals.Mock({ empty401: true }); 187 | mock.start((uri) => { 188 | 189 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 190 | connection.app('/', {}, (err, result, code, ticket) => { 191 | 192 | expect(err).to.not.exist(); 193 | expect(result).to.equal(''); 194 | expect(code).to.equal(401); 195 | 196 | mock.stop(done); 197 | }); 198 | }); 199 | }); 200 | }); 201 | 202 | describe('app()', () => { 203 | 204 | it('reuses application ticket', (done) => { 205 | 206 | const mock = new internals.Mock(); 207 | mock.start((uri) => { 208 | 209 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 210 | connection.app('/', {}, (err, result1, code1, ticket1) => { 211 | 212 | expect(err).to.not.exist(); 213 | expect(result1).to.equal('GET /'); 214 | expect(code1).to.equal(200); 215 | expect(ticket1).to.equal(connection._appTicket); 216 | 217 | connection.app('/resource', {}, (err, result2, code2, ticket2) => { 218 | 219 | expect(err).to.not.exist(); 220 | expect(result2).to.equal('GET /resource'); 221 | expect(code2).to.equal(200); 222 | expect(ticket2).to.equal(ticket1); 223 | 224 | mock.stop(done); 225 | }); 226 | }); 227 | }); 228 | }); 229 | 230 | it('handles app ticket request errors', { parallel: false }, (done) => { 231 | 232 | const mock = new internals.Mock(); 233 | mock.start((uri) => { 234 | 235 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 236 | connection._requestAppTicket = (callback) => callback(new Error('failed')); 237 | connection.app('/', {}, (err, result1, code1, ticket1) => { 238 | 239 | expect(err).to.exist(); 240 | mock.stop(done); 241 | }); 242 | }); 243 | }); 244 | }); 245 | 246 | describe('reissue()', () => { 247 | 248 | it('errors on non 200 reissue response', (done) => { 249 | 250 | const mock = new internals.Mock({ failRefresh: true }); 251 | mock.start((uri) => { 252 | 253 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 254 | connection.app('/', {}, (err, result1, code1, ticket1) => { 255 | 256 | expect(err).to.not.exist(); 257 | expect(result1).to.equal('GET /'); 258 | expect(code1).to.equal(200); 259 | expect(ticket1).to.equal(connection._appTicket); 260 | 261 | connection.reissue(ticket1, (err, ticket2) => { 262 | 263 | expect(err).to.exist(); 264 | mock.stop(done); 265 | }); 266 | }); 267 | }); 268 | }); 269 | }); 270 | 271 | describe('_request()', () => { 272 | 273 | it('errors on payload read fail', { parallel: false }, (done) => { 274 | 275 | const mock = new internals.Mock(); 276 | mock.start((uri) => { 277 | 278 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 279 | connection.app('/', {}, (err, result1, code1, ticket1) => { 280 | 281 | expect(err).to.not.exist(); 282 | 283 | let count = 0; 284 | const orig = Wreck.read; 285 | Wreck.read = function (req, options, callback) { 286 | 287 | if (++count === 1) { 288 | return orig.apply(Wreck, arguments); 289 | } 290 | 291 | Wreck.read = orig; 292 | return callback(new Error('fail read')); 293 | }; 294 | 295 | connection._request('GET', '/', null, ticket1, (err, result2, code2) => { 296 | 297 | expect(err).to.exist(); 298 | mock.stop(done); 299 | }); 300 | }); 301 | }); 302 | }); 303 | }); 304 | 305 | describe('_requestAppTicket()', () => { 306 | 307 | it('errors on socket fail', { parallel: false }, (done) => { 308 | 309 | const mock = new internals.Mock(); 310 | mock.start((uri) => { 311 | 312 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 313 | 314 | const orig = Wreck.request; 315 | Wreck.request = function (method, path, options, callback) { 316 | 317 | Wreck.request = orig; 318 | return callback(new Error('bad socket')); 319 | }; 320 | 321 | connection._requestAppTicket((err, ticket) => { 322 | 323 | expect(err).to.exist(); 324 | mock.stop(done); 325 | }); 326 | }); 327 | }); 328 | }); 329 | 330 | it('errors on payload read fail', { parallel: false }, (done) => { 331 | 332 | const mock = new internals.Mock(); 333 | mock.start((uri) => { 334 | 335 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 336 | 337 | let count = 0; 338 | const orig = Wreck.read; 339 | Wreck.read = function (req, options, callback) { 340 | 341 | if (++count === 1) { 342 | return orig.apply(Wreck, arguments); 343 | } 344 | 345 | Wreck.read = orig; 346 | return callback(new Error('fail read')); 347 | }; 348 | 349 | connection._requestAppTicket((err, ticket) => { 350 | 351 | expect(err).to.exist(); 352 | mock.stop(done); 353 | }); 354 | }); 355 | }); 356 | 357 | it('errors on invalid app response', (done) => { 358 | 359 | const mock = new internals.Mock({ failApp: true }); 360 | mock.start((uri) => { 361 | 362 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 363 | connection.app('/', {}, (err, result1, code1, ticket1) => { 364 | 365 | expect(err).to.exist(); 366 | mock.stop(done); 367 | }); 368 | }); 369 | }); 370 | }); 371 | }); 372 | 373 | 374 | internals.app = { 375 | id: 'social', 376 | scope: ['a', 'b', 'c'], 377 | key: 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn', 378 | algorithm: 'sha256' 379 | }; 380 | 381 | 382 | internals.Mock = class { 383 | 384 | constructor(options) { 385 | 386 | options = options || {}; 387 | 388 | const settings = { 389 | encryptionPassword: 'passwordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpassword', 390 | loadAppFunc: function (id, callback) { 391 | 392 | callback(null, internals.app); 393 | }, 394 | ticket: { 395 | ttl: options.ttl || 10 * 60 * 1000, 396 | iron: Iron.defaults 397 | }, 398 | hawk: {} 399 | }; 400 | 401 | this.listener = Http.createServer((req, res) => { 402 | 403 | const reply = (err, payload, code) => { 404 | 405 | code = code || (err ? err.output.statusCode : 200); 406 | const headers = (err ? err.output.headers : {}); 407 | headers['Content-Type'] = 'application/json'; 408 | const body = JSON.stringify(err ? err.output.payload : payload); 409 | 410 | res.writeHead(code, headers); 411 | res.end(body); 412 | }; 413 | 414 | Wreck.read(req, {}, (err, result) => { 415 | 416 | expect(err).to.not.exist(); 417 | 418 | if (req.url === '/oz/app') { 419 | return Oz.endpoints.app(req, result, settings, (err, payload) => { 420 | 421 | return reply(err, payload, options.failApp ? 400 : 200); 422 | }); 423 | } 424 | 425 | if (req.url === '/oz/reissue') { 426 | return Oz.endpoints.reissue(req, result, settings, (err, payload) => { 427 | 428 | return reply(err, payload, options.failRefresh ? 400 : 200); 429 | }); 430 | } 431 | 432 | Oz.server.authenticate(req, settings.encryptionPassword, settings, (err, credentials, artifacts) => { 433 | 434 | if (options.empty401) { 435 | return reply(null, '', 401); 436 | } 437 | 438 | return reply(err, req.method + ' ' + req.url); 439 | }); 440 | }); 441 | }); 442 | }; 443 | 444 | start(callback) { 445 | 446 | this.listener.listen(0, 'localhost', () => { 447 | 448 | const address = this.listener.address(); 449 | return callback('http://localhost:' + address.port); 450 | }); 451 | } 452 | 453 | stop(callback) { 454 | 455 | return this.listener.close(callback); 456 | } 457 | }; 458 | -------------------------------------------------------------------------------- /test/ticket.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Code = require('code'); 6 | const Cryptiles = require('cryptiles'); 7 | const Hoek = require('hoek'); 8 | const Iron = require('iron'); 9 | const Lab = require('lab'); 10 | const Oz = require('../lib'); 11 | 12 | 13 | // Declare internals 14 | 15 | const internals = {}; 16 | 17 | 18 | // Test shortcuts 19 | 20 | const lab = exports.lab = Lab.script(); 21 | const describe = lab.experiment; 22 | const it = lab.test; 23 | const expect = Code.expect; 24 | 25 | 26 | describe('Ticket', () => { 27 | 28 | const password = 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough'; 29 | 30 | describe('issue()', () => { 31 | 32 | it('should construct a valid ticket', (done) => { 33 | 34 | const app = { 35 | id: '123', 36 | scope: ['a', 'b'] 37 | }; 38 | 39 | const grant = { 40 | id: 's81u29n1812', 41 | user: '456', 42 | exp: Oz.hawk.utils.now() + 5000, 43 | scope: ['a'] 44 | }; 45 | 46 | const options = { 47 | ttl: 10 * 60 * 1000, 48 | ext: { 49 | public: { 50 | x: 'welcome' 51 | }, 52 | private: { 53 | x: 123 54 | } 55 | } 56 | }; 57 | 58 | Oz.ticket.issue(app, grant, password, options, (err, envelope) => { 59 | 60 | expect(err).to.not.exist(); 61 | expect(envelope.ext).to.deep.equal({ x: 'welcome' }); 62 | expect(envelope.exp).to.equal(grant.exp); 63 | expect(envelope.scope).to.deep.equal(['a']); 64 | 65 | Oz.ticket.parse(envelope.id, password, {}, (err, ticket) => { 66 | 67 | expect(err).to.not.exist(); 68 | expect(ticket.ext).to.deep.equal(options.ext); 69 | 70 | Oz.ticket.reissue(ticket, grant, password, {}, (err, envelope2) => { 71 | 72 | expect(err).to.not.exist(); 73 | expect(envelope.ext).to.deep.equal({ x: 'welcome' }); 74 | expect(envelope2.id).to.not.equal(envelope.id); 75 | done(); 76 | }); 77 | }); 78 | }); 79 | }); 80 | 81 | it('errors on missing app', (done) => { 82 | 83 | Oz.ticket.issue(null, null, password, {}, (err, ticket) => { 84 | 85 | expect(err).to.exist(); 86 | expect(err.message).to.equal('Invalid application object'); 87 | done(); 88 | }); 89 | }); 90 | 91 | it('errors on invalid app', (done) => { 92 | 93 | Oz.ticket.issue({}, null, password, {}, (err, ticket) => { 94 | 95 | expect(err).to.exist(); 96 | expect(err.message).to.equal('Invalid application object'); 97 | done(); 98 | }); 99 | }); 100 | 101 | it('errors on invalid grant (missing id)', (done) => { 102 | 103 | Oz.ticket.issue({ id: 'abc' }, {}, password, {}, (err, ticket) => { 104 | 105 | expect(err).to.exist(); 106 | expect(err.message).to.equal('Invalid grant object'); 107 | done(); 108 | }); 109 | }); 110 | 111 | it('errors on invalid grant (missing user)', (done) => { 112 | 113 | Oz.ticket.issue({ id: 'abc' }, { id: '123' }, password, {}, (err, ticket) => { 114 | 115 | expect(err).to.exist(); 116 | expect(err.message).to.equal('Invalid grant object'); 117 | done(); 118 | }); 119 | }); 120 | 121 | it('errors on invalid grant (missing exp)', (done) => { 122 | 123 | Oz.ticket.issue({ id: 'abc' }, { id: '123', user: 'steve' }, password, {}, (err, ticket) => { 124 | 125 | expect(err).to.exist(); 126 | expect(err.message).to.equal('Invalid grant object'); 127 | done(); 128 | }); 129 | }); 130 | 131 | it('errors on invalid grant (scope outside app)', (done) => { 132 | 133 | Oz.ticket.issue({ id: 'abc', scope: ['a'] }, { id: '123', user: 'steve', exp: 1442690715989, scope: ['b'] }, password, {}, (err, ticket) => { 134 | 135 | expect(err).to.exist(); 136 | expect(err.message).to.equal('Grant scope is not a subset of the application scope'); 137 | done(); 138 | }); 139 | }); 140 | 141 | it('errors on invalid app scope', (done) => { 142 | 143 | Oz.ticket.issue({ id: 'abc', scope: 'a' }, null, password, {}, (err, ticket) => { 144 | 145 | expect(err).to.exist(); 146 | expect(err.message).to.equal('scope not instance of Array'); 147 | done(); 148 | }); 149 | }); 150 | 151 | it('errors on invalid password', (done) => { 152 | 153 | Oz.ticket.issue({ id: 'abc' }, null, '', {}, (err, ticket) => { 154 | 155 | expect(err).to.exist(); 156 | expect(err.message).to.equal('Invalid encryption password'); 157 | done(); 158 | }); 159 | }); 160 | 161 | it('errors on invalid options', (done) => { 162 | 163 | Oz.ticket.issue({ id: 'abc' }, null, password, null, (err, ticket) => { 164 | 165 | expect(err).to.exist(); 166 | expect(err.message).to.equal('Invalid options object'); 167 | done(); 168 | }); 169 | }); 170 | }); 171 | 172 | describe('reissue()', () => { 173 | 174 | it('sets delegate to false', (done) => { 175 | 176 | const app = { 177 | id: '123' 178 | }; 179 | 180 | Oz.ticket.issue(app, null, password, {}, (err, envelope) => { 181 | 182 | expect(err).to.not.exist(); 183 | 184 | Oz.ticket.parse(envelope.id, password, {}, (err, ticket) => { 185 | 186 | expect(err).to.not.exist(); 187 | 188 | Oz.ticket.reissue(ticket, null, password, { issueTo: '345', delegate: false }, (err, envelope2) => { 189 | 190 | expect(err).to.not.exist(); 191 | expect(envelope2.delegate).to.be.false(); 192 | done(); 193 | }); 194 | }); 195 | }); 196 | }); 197 | 198 | it('errors on issueTo when delegate is not allowed', (done) => { 199 | 200 | const app = { 201 | id: '123' 202 | }; 203 | 204 | const options = { 205 | delegate: false 206 | }; 207 | 208 | Oz.ticket.issue(app, null, password, options, (err, envelope) => { 209 | 210 | expect(err).to.not.exist(); 211 | expect(envelope.delegate).to.be.false(); 212 | 213 | Oz.ticket.parse(envelope.id, password, {}, (err, ticket) => { 214 | 215 | expect(err).to.not.exist(); 216 | 217 | Oz.ticket.reissue(ticket, null, password, { issueTo: '345' }, (err, envelope2) => { 218 | 219 | expect(err).to.exist(); 220 | expect(err.message).to.equal('Ticket does not allow delegation'); 221 | done(); 222 | }); 223 | }); 224 | }); 225 | }); 226 | 227 | it('errors on delegate override', (done) => { 228 | 229 | const app = { 230 | id: '123' 231 | }; 232 | 233 | const options = { 234 | delegate: false 235 | }; 236 | 237 | Oz.ticket.issue(app, null, password, options, (err, envelope) => { 238 | 239 | expect(err).to.not.exist(); 240 | expect(envelope.delegate).to.be.false(); 241 | 242 | Oz.ticket.parse(envelope.id, password, {}, (err, ticket) => { 243 | 244 | expect(err).to.not.exist(); 245 | 246 | Oz.ticket.reissue(ticket, null, password, { delegate: true }, (err, envelope2) => { 247 | 248 | expect(err).to.exist(); 249 | expect(err.message).to.equal('Cannot override ticket delegate restriction'); 250 | done(); 251 | }); 252 | }); 253 | }); 254 | }); 255 | 256 | it('errors on missing parent ticket', (done) => { 257 | 258 | Oz.ticket.reissue(null, null, password, {}, (err, ticket) => { 259 | 260 | expect(err).to.exist(); 261 | expect(err.message).to.equal('Invalid parent ticket object'); 262 | done(); 263 | }); 264 | }); 265 | 266 | it('errors on missing password', (done) => { 267 | 268 | Oz.ticket.reissue({}, null, '', {}, (err, ticket) => { 269 | 270 | expect(err).to.exist(); 271 | expect(err.message).to.equal('Invalid encryption password'); 272 | done(); 273 | }); 274 | }); 275 | 276 | it('errors on missing options', (done) => { 277 | 278 | Oz.ticket.reissue({}, null, password, null, (err, ticket) => { 279 | 280 | expect(err).to.exist(); 281 | expect(err.message).to.equal('Invalid options object'); 282 | done(); 283 | }); 284 | }); 285 | 286 | it('errors on missing parent scope', (done) => { 287 | 288 | Oz.ticket.reissue({}, null, password, { scope: ['a'] }, (err, ticket) => { 289 | 290 | expect(err).to.exist(); 291 | expect(err.message).to.equal('New scope is not a subset of the parent ticket scope'); 292 | done(); 293 | }); 294 | }); 295 | 296 | it('errors on invalid parent scope', (done) => { 297 | 298 | Oz.ticket.reissue({ scope: 'a' }, null, password, { scope: ['a'] }, (err, ticket) => { 299 | 300 | expect(err).to.exist(); 301 | expect(err.message).to.equal('scope not instance of Array'); 302 | done(); 303 | }); 304 | }); 305 | 306 | it('errors on invalid options scope', (done) => { 307 | 308 | Oz.ticket.reissue({ scope: ['a'] }, null, password, { scope: 'a' }, (err, ticket) => { 309 | 310 | expect(err).to.exist(); 311 | expect(err.message).to.equal('scope not instance of Array'); 312 | done(); 313 | }); 314 | }); 315 | 316 | it('errors on invalid grant (missing id)', (done) => { 317 | 318 | Oz.ticket.reissue({}, {}, password, {}, (err, ticket) => { 319 | 320 | expect(err).to.exist(); 321 | expect(err.message).to.equal('Invalid grant object'); 322 | done(); 323 | }); 324 | }); 325 | 326 | it('errors on invalid grant (missing user)', (done) => { 327 | 328 | Oz.ticket.reissue({}, { id: 'abc' }, password, {}, (err, ticket) => { 329 | 330 | expect(err).to.exist(); 331 | expect(err.message).to.equal('Invalid grant object'); 332 | done(); 333 | }); 334 | }); 335 | 336 | it('errors on invalid grant (missing exp)', (done) => { 337 | 338 | Oz.ticket.reissue({}, { id: 'abc', user: 'steve' }, password, {}, (err, ticket) => { 339 | 340 | expect(err).to.exist(); 341 | expect(err.message).to.equal('Invalid grant object'); 342 | done(); 343 | }); 344 | }); 345 | 346 | it('errors on options.issueTo and ticket.dlg conflict', (done) => { 347 | 348 | Oz.ticket.reissue({ dlg: '123' }, null, password, { issueTo: '345' }, (err, ticket) => { 349 | 350 | expect(err).to.exist(); 351 | expect(err.message).to.equal('Cannot re-delegate'); 352 | done(); 353 | }); 354 | }); 355 | 356 | it('errors on mismatching grants (missing grant)', (done) => { 357 | 358 | Oz.ticket.reissue({ grant: '123' }, null, password, {}, (err, ticket) => { 359 | 360 | expect(err).to.exist(); 361 | expect(err.message).to.equal('Parent ticket grant does not match options.grant'); 362 | done(); 363 | }); 364 | }); 365 | 366 | it('errors on mismatching grants (missing parent)', (done) => { 367 | 368 | Oz.ticket.reissue({}, { id: '123', user: 'steve', exp: 1442690715989 }, password, {}, (err, ticket) => { 369 | 370 | expect(err).to.exist(); 371 | expect(err.message).to.equal('Parent ticket grant does not match options.grant'); 372 | done(); 373 | }); 374 | }); 375 | 376 | it('errors on mismatching grants (different)', (done) => { 377 | 378 | Oz.ticket.reissue({ grant: '234' }, { id: '123', user: 'steve', exp: 1442690715989 }, password, {}, (err, ticket) => { 379 | 380 | expect(err).to.exist(); 381 | expect(err.message).to.equal('Parent ticket grant does not match options.grant'); 382 | done(); 383 | }); 384 | }); 385 | }); 386 | 387 | describe('rsvp()', () => { 388 | 389 | it('errors on missing app', (done) => { 390 | 391 | Oz.ticket.rsvp(null, { id: '123' }, password, {}, (err, rsvp) => { 392 | 393 | expect(err).to.exist(); 394 | expect(err.message).to.equal('Invalid application object'); 395 | done(); 396 | }); 397 | }); 398 | 399 | it('errors on invalid app', (done) => { 400 | 401 | Oz.ticket.rsvp({}, { id: '123' }, password, {}, (err, rsvp) => { 402 | 403 | expect(err).to.exist(); 404 | expect(err.message).to.equal('Invalid application object'); 405 | done(); 406 | }); 407 | }); 408 | 409 | it('errors on missing grant', (done) => { 410 | 411 | Oz.ticket.rsvp({ id: '123' }, null, password, {}, (err, rsvp) => { 412 | 413 | expect(err).to.exist(); 414 | expect(err.message).to.equal('Invalid grant object'); 415 | done(); 416 | }); 417 | }); 418 | 419 | it('errors on invalid grant', (done) => { 420 | 421 | Oz.ticket.rsvp({ id: '123' }, {}, password, {}, (err, rsvp) => { 422 | 423 | expect(err).to.exist(); 424 | expect(err.message).to.equal('Invalid grant object'); 425 | done(); 426 | }); 427 | }); 428 | 429 | it('errors on missing password', (done) => { 430 | 431 | Oz.ticket.rsvp({ id: '123' }, { id: '123' }, '', {}, (err, rsvp) => { 432 | 433 | expect(err).to.exist(); 434 | expect(err.message).to.equal('Invalid encryption password'); 435 | done(); 436 | }); 437 | }); 438 | 439 | it('errors on missing options', (done) => { 440 | 441 | Oz.ticket.rsvp({ id: '123' }, { id: '123' }, password, null, (err, rsvp) => { 442 | 443 | expect(err).to.exist(); 444 | expect(err.message).to.equal('Invalid options object'); 445 | done(); 446 | }); 447 | }); 448 | 449 | it('constructs a valid rsvp', (done) => { 450 | 451 | const app = { 452 | id: '123' // App id 453 | }; 454 | 455 | const grant = { 456 | id: 's81u29n1812' // Grant 457 | }; 458 | 459 | Oz.ticket.rsvp(app, grant, password, {}, (err, envelope) => { 460 | 461 | expect(err).to.not.exist(); 462 | 463 | Oz.ticket.parse(envelope, password, {}, (err, object) => { 464 | 465 | expect(err).to.not.exist(); 466 | expect(object.app).to.equal(app.id); 467 | expect(object.grant).to.equal(grant.id); 468 | done(); 469 | }); 470 | }); 471 | }); 472 | 473 | it('fails to construct a valid rsvp due to bad Iron options', (done) => { 474 | 475 | const app = { 476 | id: '123' // App id 477 | }; 478 | 479 | const grant = { 480 | id: 's81u29n1812' // Grant 481 | }; 482 | 483 | const iron = Hoek.clone(Iron.defaults); 484 | iron.encryption = null; 485 | 486 | Oz.ticket.rsvp(app, grant, password, { iron: iron }, (err, envelope) => { 487 | 488 | expect(err).to.exist(); 489 | expect(err.message).to.equal('Bad options'); 490 | done(); 491 | }); 492 | }); 493 | }); 494 | 495 | describe('generate()', () => { 496 | 497 | it('errors on random fail', (done) => { 498 | 499 | const orig = Cryptiles.randomString; 500 | Cryptiles.randomString = function (size) { 501 | 502 | Cryptiles.randomString = orig; 503 | return new Error('fake'); 504 | }; 505 | 506 | Oz.ticket.generate({}, password, {}, (err, ticket) => { 507 | 508 | expect(err).to.exist(); 509 | expect(err.message).to.equal('fake'); 510 | done(); 511 | }); 512 | }); 513 | 514 | it('errors on missing password', (done) => { 515 | 516 | Oz.ticket.generate({}, null, {}, (err, ticket) => { 517 | 518 | expect(err).to.exist(); 519 | expect(err.message).to.equal('Empty password'); 520 | done(); 521 | }); 522 | }); 523 | 524 | it('generates a ticket with only public ext', (done) => { 525 | 526 | const input = {}; 527 | Oz.ticket.generate(input, password, { ext: { public: { x: 1 } } }, (err, ticket) => { 528 | 529 | expect(err).to.not.exist(); 530 | expect(ticket.ext.x).to.equal(1); 531 | done(); 532 | }); 533 | }); 534 | 535 | it('generates a ticket with only private ext', (done) => { 536 | 537 | const input = {}; 538 | Oz.ticket.generate(input, password, { ext: { private: { x: 1 } } }, (err, ticket) => { 539 | 540 | expect(err).to.not.exist(); 541 | expect(ticket.ext).to.not.exist(); 542 | done(); 543 | }); 544 | }); 545 | 546 | it('overrides hawk options', (done) => { 547 | 548 | const input = {}; 549 | Oz.ticket.generate(input, password, { keyBytes: 10, hmacAlgorithm: 'something' }, (err, ticket) => { 550 | 551 | expect(err).to.not.exist(); 552 | expect(ticket.key).to.have.length(10); 553 | expect(ticket.algorithm).to.equal('something'); 554 | done(); 555 | }); 556 | }); 557 | }); 558 | 559 | describe('parse()', () => { 560 | 561 | it('errors on wrong password', (done) => { 562 | 563 | const app = { 564 | id: '123' 565 | }; 566 | 567 | const grant = { 568 | id: 's81u29n1812', 569 | user: '456', 570 | exp: Oz.hawk.utils.now() + 5000, 571 | scope: ['a', 'b'] 572 | }; 573 | 574 | const options = { 575 | ttl: 10 * 60 * 1000 576 | }; 577 | 578 | Oz.ticket.issue(app, grant, password, options, (err, envelope) => { 579 | 580 | expect(err).to.not.exist(); 581 | 582 | Oz.ticket.parse(envelope.id, 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough_x', {}, (err, ticket) => { 583 | 584 | expect(err).to.exist(); 585 | expect(err.message).to.equal('Bad hmac value'); 586 | done(); 587 | }); 588 | }); 589 | }); 590 | 591 | it('errors on missing password', (done) => { 592 | 593 | Oz.ticket.parse('abc', '', {}, (err, ticket) => { 594 | 595 | expect(err).to.exist(); 596 | expect(err.message).to.equal('Invalid encryption password'); 597 | done(); 598 | }); 599 | }); 600 | 601 | it('errors on missing options', (done) => { 602 | 603 | Oz.ticket.parse('abc', password, null, (err, ticket) => { 604 | 605 | expect(err).to.exist(); 606 | expect(err.message).to.equal('Invalid options object'); 607 | done(); 608 | }); 609 | }); 610 | }); 611 | }); 612 | 613 | 614 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![oz Logo](https://raw.github.com/hueniverse/oz/master/images/oz.png) 2 | 3 | Oz is a web authorization protocol based on industry best practices. Oz combines the 4 | [Hawk](https://github.com/hueniverse/hawk) authentication protocol with the 5 | [Iron](https://github.com/hueniverse/iron) encryption protocol to provide a simple to use and 6 | secure solution for granting and authenticating third-party access to an API on behalf of a user or 7 | an application. 8 | 9 | Protocol version: **4.0.0** (Same as v1.0.0 but moved the expired ticket indicator from a header 10 | attribute to the error payload). 11 | 12 | [![Build Status](https://secure.travis-ci.org/hueniverse/oz.png)](http://travis-ci.org/hueniverse/oz) 13 | 14 | - [Protocol](#protocol) 15 | - [Workflow](#workflow) 16 | - [Application](#application) 17 | - [User](#user) 18 | - [Ticket](#ticket) 19 | - [Grant](#grant) 20 | - [Scope](#scope) 21 | - [Rsvp](#rsvp) 22 | - [API](#api) 23 | - [Shared objects](#shared-objects) 24 | - [`app` object](#app-object) 25 | - [`grant` object](#grant-object) 26 | - [`ticket` response](#ticket-response) 27 | - [`Oz.client`](#ozclient) 28 | - [`Oz.client.header(uri, method, ticket, [options])`](#ozclientheaderuri-method-ticket-options) 29 | - [`new Oz.client.Connection(options)`](#new-ozclientconnectionoptions) 30 | - [`connection.request(path, ticket, options, callback)`](#connectionrequestpath-ticket-options-callback) 31 | - [`connection.app(path, options, callback)`](#connectionapppath-options-callback) 32 | - [`connection.reissue(ticket, callback)`](#connectionreissueticket-callback) 33 | - [`Oz.endpoints`](#ozendpoints) 34 | - [Endpoints options](#endpoints-options) 35 | - [`encryptionPassword`](#encryptionpassword) 36 | - [`loadAppFunc`](#loadappfunc) 37 | - [`loadGrantFunc`](#loadgrantfunc) 38 | - [`endpoints.app(req, payload, options, callback)`](#endpointsappreq-payload-options-callback) 39 | - [`endpoints.reissue(req, payload, options, callback)`](#endpointsreissuereq-payload-options-callback) 40 | - [`endpoints.rsvp(req, payload, options, callback)`](#endpointsrsvpreq-payload-options-callback) 41 | - [`Oz.hawk`](#ozhawk) 42 | - [`Oz.scope`](#ozscope) 43 | - [`Oz.scope.validate(scope)`](#ozscopevalidatescope) 44 | - [`Oz.scope.isSubset(scope, subset)`](#ozscopeissubsetscope-subset) 45 | - [`Oz.server`](#ozserver) 46 | - [`Oz.server.authenticate(req, encryptionPassword, options, callback)`](#ozserverauthenticatereq-encryptionpassword-options-callback) 47 | - [`Oz.ticket`](#ozticket) 48 | - [Ticket options](#ticket-options) 49 | - [`ticket.issue(app, grant, encryptionPassword, options, callback)`](#ticketissueapp-grant-encryptionpassword-options-callback) 50 | - [`ticket.reissue(parentTicket, grant, encryptionPassword, options, callback)`](#ticketreissueparentticket-grant-encryptionpassword-options-callback) 51 | - [`ticket.rsvp(app, grant, encryptionPassword, options, callback)`](#ticketrsvpapp-grant-encryptionpassword-options-callback) 52 | - [`ticket.generate(ticket, encryptionPassword, options, callback)`](#ticketgenerateticket-encryptionpassword-options-callback) 53 | - [`ticket.parse(id, encryptionPassword, options, callback)`](#ticketparseid-encryptionpassword-options-callback) 54 | 55 | ## Protocol 56 | 57 | Oz builds on the well-understood concepts behind the [OAuth](https://tools.ietf.org/html/rfc5849) 58 | protocol. While the terminology has been updated to reflect the common terms used today when 59 | building applications with third-party access, the overall architecture is the same. This document 60 | assumes the reader is familiar with the OAuth 1.0a protocol workflow. 61 | 62 | ### Workflow 63 | 64 | 1. The [application](#application) uses its previously issued [Hawk](https://github.com/hueniverse/hawk) 65 | credentials to authenticate with the server and request an application [ticket](#ticket). If valid, 66 | the server issues an application ticket. 67 | 2. The application directs the [user](#user) to grant it authorization by providing the user with its 68 | application identifier. The user authenticates with the server, reviews the authorization 69 | [grant](#grant) and its [scope](#scope), and if approved the server returns an [rsvp](#rsvp). 70 | 3. The user returns to the application with the rsvp which the application uses to request a new 71 | user-specific ticket. If valid, the server returns a new ticket. 72 | 4. The application uses the user-ticket to access the user's protected resources. 73 | 74 | ### Application 75 | 76 | Oz is an application-to-server authorization protocol. This means credentials are issued only to 77 | applications, not to users. The method through which users authenticate is outside the scope of 78 | this protocol. 79 | 80 | The application represents a third-party accessing protected resource on the server. This 81 | third-party can be another server, a native app, a single-page-app, or any other application using 82 | web resources. The protected resources can be under the control of the application itself or under 83 | the control of a user who grants the application access. 84 | 85 | Each application definition includes: 86 | - `id` - a unique application identifier. 87 | - `scope` - the default application [scope](#scope). 88 | - `delegate` - if `true`, the application is allowed to delegate a ticket to another application. 89 | Defaults to `false`. 90 | 91 | Applications must be registered with the server prior to using Oz. The method through which 92 | applications register is outside the scope of this protocol. When an application registers, it is 93 | issued a set of [Hawk](https://github.com/hueniverse/hawk) credentials. The application uses these 94 | credentials to obtain an Oz [ticket](#ticket). 95 | 96 | The application Hawk credentials include: 97 | - `id` - the unique application identifier. 98 | - `key` - a shared secret used to authenticate. 99 | - `algorithm` - the HMAC algorithm used to authenticate (e.g. HMAC-SHA256). 100 | 101 | The [Hawk](https://github.com/hueniverse/hawk) protocol supports two Oz-specific header attributes 102 | which are used for authenticating Oz applications (`app` and `dlg`). 103 | 104 | ### User 105 | 106 | Applications act on behalf of users. Users are usually people with protected resources on the 107 | server who would like to use the application to access those protected resources. For the purpose 108 | of the Oz protocol, each user must have a unique identifier which is used by the protocol to record 109 | access rights. The method through which users are registered, authenticated, and managed is beyond 110 | the scope of this protocol. 111 | 112 | ### Ticket 113 | 114 | An Oz ticket is a set of [Hawk](https://github.com/hueniverse/hawk) credentials used by the 115 | application to access protected resources. Just like any other Hawk credentials, the ticket 116 | includes: 117 | - `id` - a unique identifier for the authorized access. 118 | - `key` - a shared secret used to authenticate. 119 | - `algorithm` - the HMAC algorithm used to authenticate (e.g. HMAC-SHA256). 120 | 121 | However, unlike most Hawk credential identifiers, the Oz identifier is an encoded 122 | [Iron](https://github.com/hueniverse/iron) string which when decoded contains: 123 | - `exp` - ticket expiration time in milliseconds since 1/1/1970. 124 | - `app` - the application id the ticket was issued to. 125 | - `user` - the user id if the ticket represents access to user resources. If no user id is included, 126 | the ticket allows the application access to the application own resources only. 127 | - `scope` - the ticket [scope](#scope). Defaults to `[]` if no scope is specified. 128 | - `delegate` - if `false`, the ticket cannot be delegated regardless of the application permissions. 129 | Defaults to `true` which means use the application permissions to delegate. 130 | - `grant` - if `user` is set, includes the [grant](#grant) identifier referencing the authorization 131 | granted by the user to the application. Can be a unique identifier or string encoding the grant 132 | information as long as the server is able to parse the information later. 133 | - `dlg` - if the ticket is the result of access delegation, the application id of the delegating 134 | application. 135 | - `ext` - custom server data where: 136 | - `public` - also made available to the application when the ticket is sent back. 137 | - `private` - available only within the encoded ticket. 138 | 139 | When a ticket is generated and sent to the application by the server, the response includes all of 140 | the above properties with the exception of `ext` which is included but only with the content of 141 | `ext.public` if present. 142 | 143 | The ticket expiration can be shorter than the grant expiration in which case, the application can 144 | reissue the ticket. This provides the ability to limit the time credentials are valid but allowing 145 | grants to have longer lifetime. 146 | 147 | When tickets are reissued, they can be constrained to less scope or duration, and can also be 148 | issued to another application for access delegation. 149 | 150 | #### Grant 151 | 152 | A grant is the authorization given to an application by a user to access the user's protected 153 | resources. Grants can be persisted in a database (usually to support revocation) or can be self 154 | describing (using an encoded identifier). Each grant contains: 155 | - `id` - the grant identifier, allowing the server to retrieve or recreate the grant information. 156 | - `exp` - authorization expiration time in milliseconds since 1/1/1970. 157 | - `user` - the user id who the user who authorized access. 158 | - `scope` - the authorized [scope](#scope). Defaults to the application scope if no scope is 159 | specified. 160 | 161 | #### Scope 162 | 163 | Scope is an array of strings, each represents an implementation-specific permission on the server. 164 | Each scope string adds additional permissions to the application (i.e. `['a', 'b']` grants the 165 | application access to both the `'a'` and `'b'` rights, individually). 166 | 167 | Each application has a default scope which is included in the tickets issued to the application 168 | unless the grant specifies a subset of the application scope. Applications cannot be granted scopes 169 | not present in their default set. 170 | 171 | #### Rsvp 172 | 173 | When the user authorizes the application access request, the server issues an rsvp which is an 174 | encoded string containing the application identifier, the grant identifier, and an expiration. 175 | 176 | ## API 177 | 178 | The Oz public API is offered as a full toolkit to implement the protocol as-is or to modify it to 179 | fit custom security needs. Most implementations will only need to use the [endpoints functions](#ozendpoints) 180 | methods and the [`ticket.rsvp()`](#ticketrsvpapp-grant-encryptionPassword-options-callback) method 181 | directly. 182 | 183 | ### Shared objects 184 | 185 | #### `app` object 186 | 187 | An object describing an application where: 188 | - `id` - the application identifier. 189 | - `scope` - an array with the default application scope. 190 | - `delegate` - if `true`, the application is allowed to delegate a ticket to another application. 191 | Defaults to `false`. 192 | - `key` - the shared secret used to authenticate. 193 | - `algorithm` - the HMAC algorithm used to authenticate (e.g. HMAC-SHA256). 194 | 195 | #### `grant` object 196 | 197 | An object describing a user grant where: 198 | - `id` - the grant identifier. 199 | - `app` - the application identifier. 200 | - `user` - the user identifier. 201 | - `exp` - grant expiration time in milliseconds since 1/1/1970. 202 | - `scope` - an array with the scope granted by the user to the application. 203 | 204 | #### `ticket` response 205 | 206 | An object describing a ticket and its public properties: 207 | - `id` - the ticket identifier used for making authenticated Hawk requests. 208 | - `key` - a shared secret used to authenticate. 209 | - `algorithm` - the HMAC algorithm used to authenticate (e.g. HMAC-SHA256). 210 | - `exp` - ticket expiration time in milliseconds since 1/1/1970. 211 | - `app` - the application id the ticket was issued to. 212 | - `user` - the user id if the ticket represents access to user resources. If no user id is 213 | included, the ticket allows the application access to the application own resources only. 214 | - `scope` - the ticket [scope](#scope). Defaults to `[]` if no scope is specified. 215 | - `grant` - if `user` is set, includes the [grant](#grant) identifier referencing the authorization 216 | granted by the user to the application. Can be a unique identifier or string encoding the grant 217 | information as long as the server is able to parse the information later. 218 | - `delegate` - if `false`, the ticket cannot be delegated regardless of the application permissions. 219 | Defaults to `true` which means use the application permissions to delegate. 220 | - `dlg` - if the ticket is the result of access delegation, the application id of the delegating 221 | application. 222 | - `ext` - custom server public data attached to the ticket. 223 | 224 | ### `Oz.client` 225 | 226 | Utilities used for making authenticated Oz requests. 227 | 228 | #### `Oz.client.header(uri, method, ticket, [options])` 229 | 230 | A convenience utility to generate the application Hawk request authorization header for making 231 | authenticated Oz requests where: 232 | - `uri` - the request URI. 233 | - `method` - the request HTTP method. 234 | - `ticket` - the authorization [ticket](#ticket-response). 235 | - `options` - additional Hawk `Hawk.client.header()` options. 236 | 237 | #### `new Oz.client.Connection(options)` 238 | 239 | Creates an **oz** client connection manager for easier access to protected resources. The client 240 | manages the ticket lifecycle and will automatically refresh the ticken when expired. Accepts the 241 | following options: 242 | - `endpoints` - an object containing the server protocol endpoints: 243 | `app` - the application credentials endpoint path. Defaults to `'/oz/app'`. 244 | `reissue` - the ticket reissue endpoint path. Defaults to `'/oz/reissue'`. 245 | - `uri` - required, the server full root uri without path (e.g. 'https://example.com'). 246 | - `credentials` - required, the application **hawk** credentials. 247 | 248 | ##### `connection.request(path, ticket, options, callback)` 249 | 250 | Requests a protected resource where: 251 | - `path` - the resource path (e.g. '/resource'). 252 | - `ticket` - the application or user ticket. If the ticket is expired, it will automatically 253 | attempt to refresh it. 254 | - `options` - optional configuration object where: 255 | - `method` - the HTTP method (e.g. 'GET'). Defaults to `'GET'`. 256 | - `payload` - the request payload object or string. Defaults to no payload. 257 | - `callback` - the callback method using the signature `function(err, result, code, ticket)` where: 258 | - `err` - an error condition. 259 | - `result` - the requested resource (parsed to object if JSON). 260 | - `code` - the HTTP response code. 261 | - `ticket` - the ticket used to make the request (may be different from the ticket provided 262 | when the ticket was expired and refreshed). 263 | 264 | ##### `connection.app(path, options, callback)` 265 | 266 | Requests a protected resource using a shared application ticket where: 267 | - `path` - the resource path (e.g. '/resource'). 268 | - `options` - optional configuration object where: 269 | - `method` - the HTTP method (e.g. 'GET'). Defaults to `'GET'`. 270 | - `payload` - the request payload object or string. Defaults to no payload. 271 | - `callback` - the callback method using the signature `function(err, result, code, ticket)` where: 272 | - `err` - an error condition. 273 | - `result` - the requested resource (parsed to object if JSON). 274 | - `code` - the HTTP response code. 275 | - `ticket` - the ticket used to make the request (may be different from the ticket provided 276 | when the ticket was expired and refreshed). 277 | 278 | Once an application ticket is obtained internally using the provided **hawk** credentials in the 279 | constructor, it will be reused by called to `connection.app()`. If it expires, it will 280 | automatically refresh and stored for future usage. 281 | 282 | ##### `connection.reissue(ticket, callback)` 283 | 284 | Reissues (refresh) a ticket where: 285 | - `ticket` - the ticket being reissued. 286 | - `callback` - the callback method using the signature `function(err, reissued)` where: 287 | - `err` - an error condition. 288 | - `reissued` - the reissued ticket. 289 | 290 | ### `Oz.endpoints` 291 | 292 | The endpoint methods provide a complete HTTP request handler implementation which is designed to 293 | be plugged into an HTTP framework such as [**hapi**](http://hapijs.com). The 294 | [**scarecrow**](https://github.com/hueniverse/scarecrow) plugin provides an example of how these 295 | methods integrate with an existing server implementation. 296 | 297 | #### Endpoints options 298 | 299 | Each endpoint method accepts a set of options. 300 | 301 | ##### `encryptionPassword` 302 | 303 | A required string used to generate the ticket encryption key. Must be kept confidential. The string 304 | must be the same across all Oz methods and deployments in order to allow the server to parse and 305 | generate compatible encoded strings. 306 | 307 | The `encryptionPassword` value is passed directly to the [Iron](https://github.com/hueniverse/iron) 308 | module which supports additional inputs for pre-generated encryption and integrity keys as well as 309 | password rotation. 310 | 311 | ##### `loadAppFunc` 312 | 313 | The application lookup method using the signature `function(id, next)` where: 314 | - `id` - the application identifier being requested. 315 | - `next` - the callback method used to return the requested application using the signature 316 | `function(err, app)` where: 317 | - `err` - an error condition. 318 | - `app` - an [application](#app-object) object. 319 | 320 | ##### `loadGrantFunc` 321 | 322 | The grant lookup method using the signature `function(id, next)` where: 323 | - `id` - the grant identifier being requested. 324 | - `next` - the callback method used to return the requested grant using the signature 325 | `function(err, grant, ext)` where: 326 | - `err` - an error condition. 327 | - `grant` - a [grant](#grant-object) object. 328 | - `ext` - an object used to include custom server data in the ticket and response where: 329 | - `public` - an object which is included in the response under `ticket.ext` and in 330 | the encoded ticket as `ticket.ext.public`. 331 | - `private` - an object which is included only in the encoded ticket as 332 | `ticket.ext.private`. 333 | 334 | #### `endpoints.app(req, payload, options, callback)` 335 | 336 | Authenticates an application request and if valid, issues an application ticket where: 337 | - `req` - the node HTTP server request object. 338 | - `payload` - this argument is ignored and is defined only to keep the endpoint method signature 339 | consistent with the other endpoints. 340 | - `options` - protocol [configuration](#endpoints-options) options where: 341 | - `encryptionPassword` - required. 342 | - `loadAppFunc` - required. 343 | - `ticket` - optional [ticket options](#ticket-options) used for parsing and issuance. 344 | - `hawk` - optional [Hawk](https://github.com/hapijs/hawk) configuration object. Defaults to 345 | the Hawk defaults. 346 | - `callback` - the method used to return the request result with signature `function(err, ticket)` where: 347 | - `err` - an error condition. 348 | - `ticket` - a [ticket response](#ticket-response) object. 349 | 350 | #### `endpoints.reissue(req, payload, options, callback)` 351 | 352 | Reissue an existing ticket (the ticket used to authenticate the request) where: 353 | - `req` - the node HTTP server request object. 354 | - `payload` - The HTTP request payload fully parsed into an object with the following optional keys: 355 | - `issueTo` - a different application identifier than the one of the current application. Used 356 | to delegate access between applications. Defaults to the current application. 357 | - `scope` - an array of scope strings which must be a subset of the ticket's granted scope. 358 | Defaults to the original ticket scope. 359 | - `options` - protocol [configuration](#endpoints-options) options where: 360 | - `encryptionPassword` - required. 361 | - `loadAppFunc` - required. 362 | - `loadGrantFunc` - required. 363 | - `ticket` - optional [ticket options](#ticket-options) used for parsing and issuance. 364 | - `hawk` - optional [Hawk](https://github.com/hapijs/hawk) configuration object. Defaults to 365 | the Hawk defaults. 366 | - `callback` - the method used to return the request result with signature `function(err, ticket)` where: 367 | - `err` - an error condition. 368 | - `ticket` - a [ticket response](#ticket-response) object. 369 | 370 | #### `endpoints.rsvp(req, payload, options, callback)` 371 | 372 | Authenticates an application request and if valid, exchanges the provided rsvp with a ticket where: 373 | - `req` - the node HTTP server request object. 374 | - `payload` - The HTTP request payload fully parsed into an object with the following keys: 375 | - `rsvp` - the required rsvp string provided to the user to bring back to the application after 376 | granting authorization. 377 | - `options` - protocol [configuration](#endpoints-options) options where: 378 | - `encryptionPassword` - required. 379 | - `loadAppFunc` - required. 380 | - `loadGrantFunc` - required. 381 | - `ticket` - optional [ticket options](#ticket-options) used for parsing and issuance. 382 | - `hawk` - optional [Hawk](https://github.com/hapijs/hawk) configuration object. Defaults to 383 | the Hawk defaults. 384 | - `callback` - the method used to return the request result with signature `function(err, ticket)` where: 385 | - `err` - an error condition. 386 | - `ticket` - a [ticket response](#ticket-response) object. 387 | 388 | ### `Oz.hawk` 389 | 390 | Provides direct access to the underlying [Hawk](https://github.com/hapijs/hawk) module. 391 | 392 | ### `Oz.scope` 393 | 394 | Scope manipulation utilities. 395 | 396 | #### `Oz.scope.validate(scope)` 397 | 398 | Validates a scope for proper structure (an array of unique strings) where: 399 | - `scope` - the array being validated. 400 | 401 | Returns an `Error` is the scope failed validation, otherwise `null` for valid scope. 402 | 403 | #### `Oz.scope.isSubset(scope, subset)` 404 | 405 | Checks whether a scope is a subset of another where: 406 | - `scope` - the superset. 407 | - `subset` - the subset. 408 | 409 | Returns `true` if the `subset` is fully contained with `scope`, otherwise `false. 410 | 411 | ### `Oz.server` 412 | 413 | Server implementation utilities. 414 | 415 | #### `Oz.server.authenticate(req, encryptionPassword, options, callback)` 416 | 417 | Authenticates an incoming request using [Hawk](https://github.com/hapijs/hawk) and performs 418 | additional Oz-specific validations where: 419 | Authenticates an application request and if valid, issues an application ticket where: 420 | - `req` - the node HTTP server request object. 421 | - `encryptionPassword` - the ticket [encryption password](#encryptionPassword). 422 | - `options` - protocol [configuration](#endpoints-options) options where: 423 | - `ticket` - optional [ticket options](#ticket-options) used for parsing and issuance. 424 | - `hawk` - optional [Hawk](https://github.com/hapijs/hawk) configuration object. Defaults to 425 | the Hawk defaults. 426 | - `callback` - the method used to return the request result with signature 427 | `function(err, credentials, artifacts)` where: 428 | - `err` - an error condition. 429 | - `credentials` - the decoded [ticket response](#ticket-response) object. 430 | - `artifacts` - Hawk protocol artifacts. 431 | 432 | ### `Oz.ticket` 433 | 434 | Ticket issuance, parsing, encoding, and re-issuance utilities. 435 | 436 | #### Ticket options 437 | 438 | The following are the supported ticket parsing and issuance options passed to the corresponding 439 | ticket methods. Each endpoint utilizes a different subset of these options but it is safe to pass 440 | one common object to all (it will ignore unused options): 441 | - `ttl` - when generating a ticket, sets the ticket lifetime in milliseconds. Defaults to 442 | `3600000` (1 hour) for tickets and `60000` (1 minutes) for rsvps. 443 | - `delegate` - if `false`, the ticket cannot be delegated regardless of the application permissions. 444 | Defaults to `true` which means use the application permissions to delegate. 445 | - `iron` - overrides the default [Iron](https://github.com/hueniverse/iron) configuration. 446 | - `keyBytes` - the [Hawk](https://github.com/hueniverse/hawk) key length in bytes. Defaults to 447 | `32`. 448 | - `hmacAlgorithm` - the [Hawk](https://github.com/hueniverse/hawk) HMAC algorithm. Defaults to 449 | `sha256`. 450 | - `ext` - an object used to provide custom server data to be included in the ticket (this option 451 | will be ignored when passed to an endpoint method and the `loadGrantFunc` function returns an 452 | `ext` value in the callback) where: 453 | - `public` - an object which is included in the response under `ticket.ext` and in 454 | the encoded ticket as `ticket.ext.public`. 455 | - `private` - an object which is included only in the encoded ticket as 456 | `ticket.ext.private`. 457 | 458 | #### `ticket.issue(app, grant, encryptionPassword, options, callback)` 459 | 460 | Issues a new application or user ticket where: 461 | - `app` - the application [object](#app-object) the ticket is being issued to. 462 | - `grant` - the grant [object](#grant-object) the ticket is being issued with if the ticket 463 | represents user access. `null` if the ticket is an application-only ticket. 464 | - `encryptionPassword` - the ticket [encryption password](#encryptionPassword). 465 | - `options` - ticket generation [options](#ticket-options). 466 | - `callback` - the callback method using signature `function(err, ticket)` where: 467 | - `err` - an error condition. 468 | - `ticket` - a [ticket response](#ticket-response) object. 469 | 470 | #### `ticket.reissue(parentTicket, grant, encryptionPassword, options, callback)` 471 | 472 | Reissues a application or user ticket where: 473 | - `parentTicket` - the [ticket](#ticket-response) object being reissued. 474 | - `grant` - the grant [object](#grant-object) the ticket is being issued with if the ticket 475 | represents user access. `null` if the ticket is an application-only ticket. 476 | - `encryptionPassword` - the ticket [encryption password](#encryptionPassword). 477 | - `options` - ticket generation [options](#ticket-options). 478 | - `callback` - the callback method using signature `function(err, ticket)` where: 479 | - `err` - an error condition. 480 | - `ticket` - a [ticket response](#ticket-response) object. 481 | 482 | #### `ticket.rsvp(app, grant, encryptionPassword, options, callback)` 483 | 484 | Generates an rsvp string representing a user grant where: 485 | - `app` - the application [object](#app-object) the ticket is being issued to. 486 | - `grant` - the grant [object](#grant-object) the ticket is being issued with if the ticket 487 | represents user access. `null` if the ticket is an application-only ticket. 488 | - `encryptionPassword` - the ticket [encryption password](#encryptionPassword). 489 | - `options` - ticket generation [options](#ticket-options). 490 | - `callback` - the callback method using signature `function(err, rsvp)` where: 491 | - `err` - an error condition. 492 | - `rsvp` - the rsvp string. 493 | 494 | #### `ticket.generate(ticket, encryptionPassword, options, callback)` 495 | 496 | Adds the cryptographic properties to a ticket and prepares the response where: 497 | - `ticket` - an incomplete [ticket](#ticket-response) object with the following: 498 | - `exp` 499 | - `app` 500 | - `user` 501 | - `scope` 502 | - `grant` 503 | - `dlg` 504 | - `encryptionPassword` - the ticket [encryption password](#encryptionPassword). 505 | - `options` - ticket generation [options](#ticket-options). 506 | - `callback` - the callback method using signature `function(err, ticket)` where: 507 | - `err` - an error condition. 508 | - `ticket` - the completed [ticket response](#ticket-response) object. 509 | 510 | #### `ticket.parse(id, encryptionPassword, options, callback)` 511 | 512 | Decodes a ticket identifier into a ticket response where: 513 | - `id` - the ticket id which contains the encoded ticket information. 514 | - `encryptionPassword` - the ticket [encryption password](#encryptionPassword). 515 | - `options` - ticket generation [options](#ticket-options). 516 | - `callback` - the callback method using signature `function(err, ticket)` where: 517 | - `err` - an error condition. 518 | - `ticket` - a [ticket response](#ticket-response) object. 519 | -------------------------------------------------------------------------------- /test/endpoints.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Code = require('code'); 6 | const Iron = require('iron'); 7 | const Lab = require('lab'); 8 | const Oz = require('../lib'); 9 | 10 | 11 | // Declare internals 12 | 13 | const internals = {}; 14 | 15 | 16 | // Test shortcuts 17 | 18 | const lab = exports.lab = Lab.script(); 19 | const describe = lab.experiment; 20 | const it = lab.test; 21 | const before = lab.before; 22 | const expect = Code.expect; 23 | 24 | 25 | describe('Endpoints', () => { 26 | 27 | const encryptionPassword = 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough'; 28 | 29 | const apps = { 30 | social: { 31 | id: 'social', 32 | scope: ['a', 'b', 'c'], 33 | key: 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn', 34 | algorithm: 'sha256' 35 | }, 36 | network: { 37 | id: 'network', 38 | scope: ['b', 'x'], 39 | key: 'witf745itwn7ey4otnw7eyi4t7syeir7bytise7rbyi', 40 | algorithm: 'sha256' 41 | } 42 | }; 43 | 44 | let appTicket = null; 45 | 46 | before((done) => { 47 | 48 | const req = { 49 | method: 'POST', 50 | url: '/oz/app', 51 | headers: { 52 | host: 'example.com', 53 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).field 54 | } 55 | }; 56 | 57 | const options = { 58 | encryptionPassword: encryptionPassword, 59 | loadAppFunc: function (id, callback) { 60 | 61 | callback(null, apps[id]); 62 | } 63 | }; 64 | 65 | Oz.endpoints.app(req, null, options, (err, ticket) => { 66 | 67 | expect(err).to.not.exist(); 68 | appTicket = ticket; 69 | done(); 70 | }); 71 | }); 72 | 73 | describe('app()', () => { 74 | 75 | it('overrides defaults', (done) => { 76 | 77 | const req = { 78 | method: 'POST', 79 | url: '/oz/app', 80 | headers: { 81 | host: 'example.com', 82 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).field 83 | } 84 | }; 85 | 86 | const options = { 87 | encryptionPassword: encryptionPassword, 88 | loadAppFunc: function (id, callback) { 89 | 90 | callback(null, apps.social); 91 | }, 92 | ticket: { 93 | ttl: 10 * 60 * 1000, 94 | iron: Iron.defaults 95 | }, 96 | hawk: {} 97 | }; 98 | 99 | Oz.endpoints.app(req, null, options, (err, ticket) => { 100 | 101 | expect(err).to.not.exist(); 102 | done(); 103 | }); 104 | }); 105 | 106 | it('fails on invalid app request (bad credentials)', (done) => { 107 | 108 | const req = { 109 | method: 'POST', 110 | url: '/oz/app', 111 | headers: { 112 | host: 'example.com', 113 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).field 114 | } 115 | }; 116 | 117 | const options = { 118 | encryptionPassword: encryptionPassword, 119 | loadAppFunc: function (id, callback) { 120 | 121 | callback(null, apps.network); 122 | } 123 | }; 124 | 125 | Oz.endpoints.app(req, null, options, (err, ticket) => { 126 | 127 | expect(err).to.exist(); 128 | expect(err.message).to.equal('Bad mac'); 129 | done(); 130 | }); 131 | }); 132 | }); 133 | 134 | describe('reissue()', () => { 135 | 136 | it('allows null payload', (done) => { 137 | 138 | const req = { 139 | method: 'POST', 140 | url: '/oz/reissue', 141 | headers: { 142 | host: 'example.com', 143 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).field 144 | } 145 | }; 146 | 147 | const options = { 148 | encryptionPassword: encryptionPassword, 149 | loadAppFunc: function (id, callback) { 150 | 151 | callback(null, apps.social); 152 | } 153 | }; 154 | 155 | Oz.endpoints.reissue(req, null, options, (err, ticket) => { 156 | 157 | expect(err).to.not.exist(); 158 | done(); 159 | }); 160 | }); 161 | 162 | it('overrides defaults', (done) => { 163 | 164 | const req = { 165 | method: 'POST', 166 | url: '/oz/reissue', 167 | headers: { 168 | host: 'example.com', 169 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).field 170 | } 171 | }; 172 | 173 | const options = { 174 | encryptionPassword: encryptionPassword, 175 | loadAppFunc: function (id, callback) { 176 | 177 | callback(null, apps.social); 178 | }, 179 | ticket: { 180 | ttl: 10 * 60 * 1000, 181 | iron: Iron.defaults 182 | }, 183 | hawk: {} 184 | }; 185 | 186 | Oz.endpoints.reissue(req, {}, options, (err, ticket) => { 187 | 188 | expect(err).to.not.exist(); 189 | done(); 190 | }); 191 | }); 192 | 193 | it('reissues expired ticket', (done) => { 194 | 195 | let req = { 196 | method: 'POST', 197 | url: '/oz/app', 198 | headers: { 199 | host: 'example.com', 200 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).field 201 | } 202 | }; 203 | 204 | const options = { 205 | encryptionPassword: encryptionPassword, 206 | loadAppFunc: function (id, callback) { 207 | 208 | callback(null, apps[id]); 209 | }, 210 | ticket: { 211 | ttl: 5 212 | } 213 | }; 214 | 215 | Oz.endpoints.app(req, null, options, (err, ticket) => { 216 | 217 | expect(err).to.not.exist(); 218 | 219 | req = { 220 | method: 'POST', 221 | url: '/oz/reissue', 222 | headers: { 223 | host: 'example.com', 224 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).field 225 | } 226 | }; 227 | 228 | setTimeout(() => { 229 | 230 | Oz.endpoints.reissue(req, {}, options, (err, reissued) => { 231 | 232 | expect(err).to.not.exist(); 233 | done(); 234 | }); 235 | }, 10); 236 | }); 237 | }); 238 | 239 | it('fails on app load error', (done) => { 240 | 241 | const req = { 242 | method: 'POST', 243 | url: '/oz/reissue', 244 | headers: { 245 | host: 'example.com', 246 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).field 247 | } 248 | }; 249 | 250 | const options = { 251 | encryptionPassword: encryptionPassword, 252 | loadAppFunc: function (id, callback) { 253 | 254 | callback(new Error('not found')); 255 | } 256 | }; 257 | 258 | Oz.endpoints.reissue(req, {}, options, (err, ticket) => { 259 | 260 | expect(err).to.exist(); 261 | expect(err.message).to.equal('not found'); 262 | done(); 263 | }); 264 | }); 265 | 266 | it('fails on missing app delegation rights', (done) => { 267 | 268 | const req = { 269 | method: 'POST', 270 | url: '/oz/reissue', 271 | headers: { 272 | host: 'example.com', 273 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).field 274 | } 275 | }; 276 | 277 | const options = { 278 | encryptionPassword: encryptionPassword, 279 | loadAppFunc: function (id, callback) { 280 | 281 | callback(null, apps.social); 282 | } 283 | }; 284 | 285 | Oz.endpoints.reissue(req, { issueTo: apps.network.id }, options, (err, ticket) => { 286 | 287 | expect(err).to.exist(); 288 | expect(err.message).to.equal('Application has no delegation rights'); 289 | done(); 290 | }); 291 | }); 292 | 293 | it('fails on invalid reissue (request params)', (done) => { 294 | 295 | const options = { 296 | encryptionPassword: encryptionPassword, 297 | loadAppFunc: function (id, callback) { 298 | 299 | callback(null, apps[id]); 300 | } 301 | }; 302 | 303 | const payload = { 304 | issueTo: null 305 | }; 306 | 307 | const req = { 308 | method: 'POST', 309 | url: '/oz/reissue', 310 | headers: { 311 | host: 'example.com', 312 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).field 313 | } 314 | }; 315 | 316 | Oz.endpoints.reissue(req, payload, options, (err, delegatedTicket) => { 317 | 318 | expect(err).to.exist(); 319 | expect(err.message).to.equal('child "issueTo" fails because ["issueTo" must be a string]'); 320 | done(); 321 | }); 322 | }); 323 | 324 | it('fails on invalid reissue (fails auth)', (done) => { 325 | 326 | const options = { 327 | encryptionPassword: encryptionPassword, 328 | loadAppFunc: function (id, callback) { 329 | 330 | callback(null, apps[id]); 331 | } 332 | }; 333 | 334 | const req = { 335 | method: 'POST', 336 | url: '/oz/reissue', 337 | headers: { 338 | host: 'example.com', 339 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).field 340 | } 341 | }; 342 | 343 | options.encryptionPassword = 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough_x'; 344 | Oz.endpoints.reissue(req, {}, options, (err, delegatedTicket) => { 345 | 346 | expect(err).to.exist(); 347 | expect(err.message).to.equal('Bad hmac value'); 348 | done(); 349 | }); 350 | }); 351 | 352 | it('fails on invalid reissue (invalid app)', (done) => { 353 | 354 | const options = { 355 | encryptionPassword: encryptionPassword, 356 | loadAppFunc: function (id, callback) { 357 | 358 | callback(null, apps[id]); 359 | } 360 | }; 361 | 362 | const req = { 363 | method: 'POST', 364 | url: '/oz/reissue', 365 | headers: { 366 | host: 'example.com', 367 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).field 368 | } 369 | }; 370 | 371 | options.loadAppFunc = function (id, callback) { 372 | 373 | callback(null, null); 374 | }; 375 | 376 | Oz.endpoints.reissue(req, {}, options, (err, delegatedTicket) => { 377 | 378 | expect(err).to.exist(); 379 | expect(err.message).to.equal('Invalid application'); 380 | done(); 381 | }); 382 | }); 383 | 384 | it('fails on invalid reissue (missing grant)', (done) => { 385 | 386 | const options = { 387 | encryptionPassword: encryptionPassword, 388 | loadAppFunc: function (id, callback) { 389 | 390 | callback(null, apps[id]); 391 | } 392 | }; 393 | 394 | const grant = { 395 | id: 'a1b2c3d4e5f6g7h8i9j0', 396 | app: appTicket.app, 397 | user: 'john', 398 | exp: Oz.hawk.utils.now() + 60000 399 | }; 400 | 401 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 402 | 403 | expect(err).to.not.exist(); 404 | 405 | options.loadGrantFunc = function (id, callback) { 406 | 407 | callback(null, grant); 408 | }; 409 | 410 | const payload = { rsvp: rsvp }; 411 | 412 | const req1 = { 413 | method: 'POST', 414 | url: '/oz/rsvp', 415 | headers: { 416 | host: 'example.com', 417 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 418 | } 419 | }; 420 | 421 | Oz.endpoints.rsvp(req1, payload, options, (err, ticket) => { 422 | 423 | expect(err).to.not.exist(); 424 | 425 | const req2 = { 426 | method: 'POST', 427 | url: '/oz/reissue', 428 | headers: { 429 | host: 'example.com', 430 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).field 431 | } 432 | }; 433 | 434 | options.loadGrantFunc = function (id, callback) { 435 | 436 | callback(null, null); 437 | }; 438 | 439 | Oz.endpoints.reissue(req2, {}, options, (err, delegatedTicket) => { 440 | 441 | expect(err).to.exist(); 442 | expect(err.message).to.equal('Invalid grant'); 443 | done(); 444 | }); 445 | }); 446 | }); 447 | }); 448 | 449 | it('fails on invalid reissue (grant error)', (done) => { 450 | 451 | const options = { 452 | encryptionPassword: encryptionPassword, 453 | loadAppFunc: function (id, callback) { 454 | 455 | callback(null, apps[id]); 456 | } 457 | }; 458 | 459 | const grant = { 460 | id: 'a1b2c3d4e5f6g7h8i9j0', 461 | app: appTicket.app, 462 | user: 'john', 463 | exp: Oz.hawk.utils.now() + 60000 464 | }; 465 | 466 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 467 | 468 | expect(err).to.not.exist(); 469 | 470 | options.loadGrantFunc = function (id, callback) { 471 | 472 | callback(null, grant); 473 | }; 474 | 475 | const payload = { rsvp: rsvp }; 476 | 477 | const req1 = { 478 | method: 'POST', 479 | url: '/oz/rsvp', 480 | headers: { 481 | host: 'example.com', 482 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 483 | } 484 | }; 485 | 486 | Oz.endpoints.rsvp(req1, payload, options, (err, ticket) => { 487 | 488 | expect(err).to.not.exist(); 489 | 490 | const req2 = { 491 | method: 'POST', 492 | url: '/oz/reissue', 493 | headers: { 494 | host: 'example.com', 495 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).field 496 | } 497 | }; 498 | 499 | options.loadGrantFunc = function (id, callback) { 500 | 501 | callback(new Error('what?')); 502 | }; 503 | 504 | Oz.endpoints.reissue(req2, {}, options, (err, delegatedTicket) => { 505 | 506 | expect(err).to.exist(); 507 | expect(err.message).to.equal('what?'); 508 | done(); 509 | }); 510 | }); 511 | }); 512 | }); 513 | 514 | it('fails on invalid reissue (grant user mismatch)', (done) => { 515 | 516 | const options = { 517 | encryptionPassword: encryptionPassword, 518 | loadAppFunc: function (id, callback) { 519 | 520 | callback(null, apps[id]); 521 | } 522 | }; 523 | 524 | const grant = { 525 | id: 'a1b2c3d4e5f6g7h8i9j0', 526 | app: appTicket.app, 527 | user: 'john', 528 | exp: Oz.hawk.utils.now() + 60000 529 | }; 530 | 531 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 532 | 533 | expect(err).to.not.exist(); 534 | 535 | options.loadGrantFunc = function (id, callback) { 536 | 537 | callback(null, grant); 538 | }; 539 | 540 | const payload = { rsvp: rsvp }; 541 | 542 | const req1 = { 543 | method: 'POST', 544 | url: '/oz/rsvp', 545 | headers: { 546 | host: 'example.com', 547 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 548 | } 549 | }; 550 | 551 | Oz.endpoints.rsvp(req1, payload, options, (err, ticket) => { 552 | 553 | expect(err).to.not.exist(); 554 | 555 | const req2 = { 556 | method: 'POST', 557 | url: '/oz/reissue', 558 | headers: { 559 | host: 'example.com', 560 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).field 561 | } 562 | }; 563 | 564 | options.loadGrantFunc = function (id, callback) { 565 | 566 | grant.user = 'steve'; 567 | callback(null, grant); 568 | }; 569 | 570 | Oz.endpoints.reissue(req2, {}, options, (err, delegatedTicket) => { 571 | 572 | expect(err).to.exist(); 573 | expect(err.message).to.equal('Invalid grant'); 574 | done(); 575 | }); 576 | }); 577 | }); 578 | }); 579 | 580 | it('fails on invalid reissue (grant missing exp)', (done) => { 581 | 582 | const options = { 583 | encryptionPassword: encryptionPassword, 584 | loadAppFunc: function (id, callback) { 585 | 586 | callback(null, apps[id]); 587 | } 588 | }; 589 | 590 | const grant = { 591 | id: 'a1b2c3d4e5f6g7h8i9j0', 592 | app: appTicket.app, 593 | user: 'john', 594 | exp: Oz.hawk.utils.now() + 60000 595 | }; 596 | 597 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 598 | 599 | expect(err).to.not.exist(); 600 | 601 | options.loadGrantFunc = function (id, callback) { 602 | 603 | callback(null, grant); 604 | }; 605 | 606 | const payload = { rsvp: rsvp }; 607 | 608 | const req1 = { 609 | method: 'POST', 610 | url: '/oz/rsvp', 611 | headers: { 612 | host: 'example.com', 613 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 614 | } 615 | }; 616 | 617 | Oz.endpoints.rsvp(req1, payload, options, (err, ticket) => { 618 | 619 | expect(err).to.not.exist(); 620 | 621 | const req2 = { 622 | method: 'POST', 623 | url: '/oz/reissue', 624 | headers: { 625 | host: 'example.com', 626 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).field 627 | } 628 | }; 629 | 630 | options.loadGrantFunc = function (id, callback) { 631 | 632 | delete grant.exp; 633 | callback(null, grant); 634 | }; 635 | 636 | Oz.endpoints.reissue(req2, {}, options, (err, delegatedTicket) => { 637 | 638 | expect(err).to.exist(); 639 | expect(err.message).to.equal('Invalid grant'); 640 | done(); 641 | }); 642 | }); 643 | }); 644 | }); 645 | 646 | it('fails on invalid reissue (grant app does not match app or dlg)', (done) => { 647 | 648 | const applications = { 649 | social: { 650 | id: 'social', 651 | key: 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn', 652 | algorithm: 'sha256', 653 | delegate: true 654 | }, 655 | network: { 656 | id: 'network', 657 | key: 'witf745itwn7ey4otnw7eyi4t7syeir7bytise7rbyi', 658 | algorithm: 'sha256' 659 | } 660 | }; 661 | 662 | // The app requests an app ticket using Oz.hawk authentication 663 | 664 | let req = { 665 | method: 'POST', 666 | url: '/oz/app', 667 | headers: { 668 | host: 'example.com', 669 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', applications.social).field 670 | } 671 | }; 672 | 673 | const options = { 674 | encryptionPassword: encryptionPassword, 675 | loadAppFunc: function (id, callback) { 676 | 677 | callback(null, applications[id]); 678 | } 679 | }; 680 | 681 | Oz.endpoints.app(req, null, options, (err, applicationTicket) => { 682 | 683 | expect(err).to.not.exist(); 684 | 685 | // The user is redirected to the server, logs in, and grant app access, resulting in an rsvp 686 | 687 | const grant = { 688 | id: 'a1b2c3d4e5f6g7h8i9j0', 689 | app: applicationTicket.app, 690 | user: 'john', 691 | exp: Oz.hawk.utils.now() + 60000 692 | }; 693 | 694 | Oz.ticket.rsvp(applications.social, grant, encryptionPassword, {}, (err, rsvp) => { 695 | 696 | expect(err).to.not.exist(); 697 | 698 | // After granting app access, the user returns to the app with the rsvp 699 | 700 | options.loadGrantFunc = function (id, callback) { 701 | 702 | callback(null, grant); 703 | }; 704 | 705 | // The app exchanges the rsvp for a ticket 706 | 707 | let payload = { rsvp: rsvp }; 708 | 709 | req = { 710 | method: 'POST', 711 | url: '/oz/rsvp', 712 | headers: { 713 | host: 'example.com', 714 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', applicationTicket).field 715 | } 716 | }; 717 | 718 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 719 | 720 | expect(err).to.not.exist(); 721 | 722 | // The app reissues the ticket with delegation to another app 723 | 724 | payload = { 725 | issueTo: applications.network.id 726 | }; 727 | 728 | req = { 729 | method: 'POST', 730 | url: '/oz/reissue', 731 | headers: { 732 | host: 'example.com', 733 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).field 734 | } 735 | }; 736 | 737 | Oz.endpoints.reissue(req, payload, options, (err, delegatedTicket) => { 738 | 739 | expect(err).to.not.exist(); 740 | 741 | // The other app reissues their ticket 742 | 743 | req = { 744 | method: 'POST', 745 | url: '/oz/reissue', 746 | headers: { 747 | host: 'example.com', 748 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', delegatedTicket).field 749 | } 750 | }; 751 | 752 | options.loadGrantFunc = function (id, callback) { 753 | 754 | grant.app = 'xyz'; 755 | callback(null, grant); 756 | }; 757 | 758 | Oz.endpoints.reissue(req, {}, options, (err, reissuedDelegatedTicket) => { 759 | 760 | expect(err).to.exist(); 761 | expect(err.message).to.equal('Invalid grant'); 762 | done(); 763 | }); 764 | }); 765 | }); 766 | }); 767 | }); 768 | }); 769 | }); 770 | 771 | describe('rsvp()', () => { 772 | 773 | it('overrides defaults', (done) => { 774 | 775 | const options = { 776 | encryptionPassword: encryptionPassword, 777 | loadAppFunc: function (id, callback) { 778 | 779 | callback(null, apps[id]); 780 | }, 781 | ticket: { 782 | iron: Iron.defaults 783 | } 784 | }; 785 | 786 | const grant = { 787 | id: 'a1b2c3d4e5f6g7h8i9j0', 788 | app: appTicket.app, 789 | user: 'john', 790 | exp: Oz.hawk.utils.now() + 60000 791 | }; 792 | 793 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 794 | 795 | expect(err).to.not.exist(); 796 | 797 | options.loadGrantFunc = function (id, callback) { 798 | 799 | callback(null, grant); 800 | }; 801 | 802 | const payload = { rsvp: rsvp }; 803 | 804 | const req = { 805 | method: 'POST', 806 | url: '/oz/rsvp', 807 | headers: { 808 | host: 'example.com', 809 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 810 | } 811 | }; 812 | 813 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 814 | 815 | expect(err).to.not.exist(); 816 | done(); 817 | }); 818 | }); 819 | }); 820 | 821 | it('errors on invalid authentication', (done) => { 822 | 823 | const options = { 824 | encryptionPassword: encryptionPassword, 825 | loadAppFunc: function (id, callback) { 826 | 827 | callback(null, apps[id]); 828 | }, 829 | ticket: { 830 | iron: Iron.defaults 831 | } 832 | }; 833 | 834 | const grant = { 835 | id: 'a1b2c3d4e5f6g7h8i9j0', 836 | app: appTicket.app, 837 | user: 'john', 838 | exp: Oz.hawk.utils.now() + 60000 839 | }; 840 | 841 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 842 | 843 | expect(err).to.not.exist(); 844 | 845 | options.loadGrantFunc = function (id, callback) { 846 | 847 | callback(null, grant); 848 | }; 849 | 850 | const payload = { rsvp: rsvp }; 851 | 852 | const req = { 853 | method: 'POST', 854 | url: '/oz/rsvp', 855 | headers: { 856 | host: 'example.com' 857 | } 858 | }; 859 | 860 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 861 | 862 | expect(err).to.exist(); 863 | done(); 864 | }); 865 | }); 866 | }); 867 | 868 | it('errors on expired ticket', (done) => { 869 | 870 | // App ticket 871 | 872 | let req = { 873 | method: 'POST', 874 | url: '/oz/app', 875 | headers: { 876 | host: 'example.com', 877 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).field 878 | } 879 | }; 880 | 881 | const options = { 882 | encryptionPassword: encryptionPassword, 883 | loadAppFunc: function (id, callback) { 884 | 885 | callback(null, apps[id]); 886 | }, 887 | ticket: { 888 | ttl: 5 889 | } 890 | }; 891 | 892 | Oz.endpoints.app(req, null, options, (err, applicationTicket) => { 893 | 894 | expect(err).to.not.exist(); 895 | 896 | const grant = { 897 | id: 'a1b2c3d4e5f6g7h8i9j0', 898 | app: applicationTicket.app, 899 | user: 'john', 900 | exp: Oz.hawk.utils.now() + 60000 901 | }; 902 | 903 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 904 | 905 | expect(err).to.not.exist(); 906 | 907 | options.loadGrantFunc = function (id, callback) { 908 | 909 | callback(null, grant); 910 | }; 911 | 912 | const payload = { rsvp: rsvp }; 913 | 914 | req = { 915 | method: 'POST', 916 | url: '/oz/rsvp', 917 | headers: { 918 | host: 'example.com', 919 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', applicationTicket).field 920 | } 921 | }; 922 | 923 | setTimeout(() => { 924 | 925 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 926 | 927 | expect(err).to.exist(); 928 | done(); 929 | }); 930 | }, 10); 931 | }); 932 | }); 933 | }); 934 | 935 | it('errors on missing payload', (done) => { 936 | 937 | Oz.endpoints.rsvp({}, null, {}, (err, ticket) => { 938 | 939 | expect(err).to.exist(); 940 | expect(err.message).to.equal('Missing required payload'); 941 | done(); 942 | }); 943 | }); 944 | 945 | it('fails on invalid rsvp (request params)', (done) => { 946 | 947 | const options = { 948 | encryptionPassword: encryptionPassword, 949 | loadAppFunc: function (id, callback) { 950 | 951 | callback(null, apps[id]); 952 | } 953 | }; 954 | 955 | const grant = { 956 | id: 'a1b2c3d4e5f6g7h8i9j0', 957 | app: appTicket.app, 958 | user: 'john', 959 | exp: Oz.hawk.utils.now() + 60000 960 | }; 961 | 962 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 963 | 964 | expect(err).to.not.exist(); 965 | 966 | options.loadGrantFunc = function (id, callback) { 967 | 968 | callback(null, grant); 969 | }; 970 | 971 | const payload = { rsvp: '' }; 972 | 973 | const req = { 974 | method: 'POST', 975 | url: '/oz/rsvp', 976 | headers: { 977 | host: 'example.com', 978 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 979 | } 980 | }; 981 | 982 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 983 | 984 | expect(err).to.exist(); 985 | expect(err.message).to.equal('child "rsvp" fails because ["rsvp" is not allowed to be empty]'); 986 | done(); 987 | }); 988 | }); 989 | }); 990 | 991 | it('fails on invalid rsvp (invalid auth)', (done) => { 992 | 993 | const options = { 994 | encryptionPassword: encryptionPassword, 995 | loadAppFunc: function (id, callback) { 996 | 997 | callback(null, apps[id]); 998 | } 999 | }; 1000 | 1001 | const grant = { 1002 | id: 'a1b2c3d4e5f6g7h8i9j0', 1003 | app: appTicket.app, 1004 | user: 'john', 1005 | exp: Oz.hawk.utils.now() + 60000 1006 | }; 1007 | 1008 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 1009 | 1010 | expect(err).to.not.exist(); 1011 | 1012 | options.loadGrantFunc = function (id, callback) { 1013 | 1014 | callback(null, grant); 1015 | }; 1016 | 1017 | const payload = { rsvp: 'abc' }; 1018 | 1019 | const req = { 1020 | method: 'POST', 1021 | url: '/oz/rsvp', 1022 | headers: { 1023 | host: 'example.com', 1024 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 1025 | } 1026 | }; 1027 | 1028 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 1029 | 1030 | expect(err).to.exist(); 1031 | expect(err.message).to.equal('Incorrect number of sealed components'); 1032 | done(); 1033 | }); 1034 | }); 1035 | }); 1036 | 1037 | it('fails on invalid rsvp (user ticket)', (done) => { 1038 | 1039 | const options = { 1040 | encryptionPassword: encryptionPassword, 1041 | loadAppFunc: function (id, callback) { 1042 | 1043 | callback(null, apps[id]); 1044 | } 1045 | }; 1046 | 1047 | const grant = { 1048 | id: 'a1b2c3d4e5f6g7h8i9j0', 1049 | app: appTicket.app, 1050 | user: 'john', 1051 | exp: Oz.hawk.utils.now() + 60000 1052 | }; 1053 | 1054 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 1055 | 1056 | expect(err).to.not.exist(); 1057 | 1058 | options.loadGrantFunc = function (id, callback) { 1059 | 1060 | callback(null, grant); 1061 | }; 1062 | 1063 | const body = { rsvp: rsvp }; 1064 | 1065 | const req1 = { 1066 | method: 'POST', 1067 | url: '/oz/rsvp', 1068 | headers: { 1069 | host: 'example.com', 1070 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 1071 | } 1072 | }; 1073 | 1074 | Oz.endpoints.rsvp(req1, body, options, (err, ticket1) => { 1075 | 1076 | expect(err).to.not.exist(); 1077 | 1078 | const req2 = { 1079 | method: 'POST', 1080 | url: '/oz/rsvp', 1081 | headers: { 1082 | host: 'example.com', 1083 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', ticket1).field 1084 | } 1085 | }; 1086 | 1087 | Oz.endpoints.rsvp(req2, body, options, (err, ticket2) => { 1088 | 1089 | expect(err).to.exist(); 1090 | expect(err.message).to.equal('User ticket cannot be used on an application endpoint'); 1091 | done(); 1092 | }); 1093 | }); 1094 | }); 1095 | }); 1096 | 1097 | it('fails on invalid rsvp (mismatching apps)', (done) => { 1098 | 1099 | const options = { 1100 | encryptionPassword: encryptionPassword, 1101 | loadAppFunc: function (id, callback) { 1102 | 1103 | callback(null, apps[id]); 1104 | } 1105 | }; 1106 | 1107 | const grant = { 1108 | id: 'a1b2c3d4e5f6g7h8i9j0', 1109 | app: appTicket.app, 1110 | user: 'john', 1111 | exp: Oz.hawk.utils.now() + 60000 1112 | }; 1113 | 1114 | Oz.ticket.rsvp(apps.network, grant, encryptionPassword, {}, (err, rsvp) => { 1115 | 1116 | expect(err).to.not.exist(); 1117 | 1118 | options.loadGrantFunc = function (id, callback) { 1119 | 1120 | callback(null, grant); 1121 | }; 1122 | 1123 | const payload = { rsvp: rsvp }; 1124 | 1125 | const req = { 1126 | method: 'POST', 1127 | url: '/oz/rsvp', 1128 | headers: { 1129 | host: 'example.com', 1130 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 1131 | } 1132 | }; 1133 | 1134 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 1135 | 1136 | expect(err).to.exist(); 1137 | expect(err.message).to.equal('Mismatching ticket and rsvp apps'); 1138 | done(); 1139 | }); 1140 | }); 1141 | }); 1142 | 1143 | it('fails on invalid rsvp (expired rsvp)', (done) => { 1144 | 1145 | const options = { 1146 | encryptionPassword: encryptionPassword, 1147 | loadAppFunc: function (id, callback) { 1148 | 1149 | callback(null, apps[id]); 1150 | } 1151 | }; 1152 | 1153 | const grant = { 1154 | id: 'a1b2c3d4e5f6g7h8i9j0', 1155 | app: appTicket.app, 1156 | user: 'john', 1157 | exp: Oz.hawk.utils.now() + 60000 1158 | }; 1159 | 1160 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, { ttl: 1 }, (err, rsvp) => { 1161 | 1162 | expect(err).to.not.exist(); 1163 | 1164 | options.loadGrantFunc = function (id, callback) { 1165 | 1166 | callback(null, grant); 1167 | }; 1168 | 1169 | const payload = { rsvp: rsvp }; 1170 | 1171 | const req = { 1172 | method: 'POST', 1173 | url: '/oz/rsvp', 1174 | headers: { 1175 | host: 'example.com', 1176 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 1177 | } 1178 | }; 1179 | 1180 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 1181 | 1182 | expect(err).to.exist(); 1183 | expect(err.message).to.equal('Expired rsvp'); 1184 | done(); 1185 | }); 1186 | }); 1187 | }); 1188 | 1189 | it('fails on invalid rsvp (expired grant)', (done) => { 1190 | 1191 | const options = { 1192 | encryptionPassword: encryptionPassword, 1193 | loadAppFunc: function (id, callback) { 1194 | 1195 | callback(null, apps[id]); 1196 | } 1197 | }; 1198 | 1199 | const grant = { 1200 | id: 'a1b2c3d4e5f6g7h8i9j0', 1201 | app: appTicket.app, 1202 | user: 'john', 1203 | exp: Oz.hawk.utils.now() - 1000 1204 | }; 1205 | 1206 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 1207 | 1208 | expect(err).to.not.exist(); 1209 | 1210 | options.loadGrantFunc = function (id, callback) { 1211 | 1212 | callback(null, grant); 1213 | }; 1214 | 1215 | const payload = { rsvp: rsvp }; 1216 | 1217 | const req = { 1218 | method: 'POST', 1219 | url: '/oz/rsvp', 1220 | headers: { 1221 | host: 'example.com', 1222 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 1223 | } 1224 | }; 1225 | 1226 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 1227 | 1228 | expect(err).to.exist(); 1229 | expect(err.message).to.equal('Invalid grant'); 1230 | done(); 1231 | }); 1232 | }); 1233 | }); 1234 | 1235 | it('fails on invalid rsvp (missing grant)', (done) => { 1236 | 1237 | const options = { 1238 | encryptionPassword: encryptionPassword, 1239 | loadAppFunc: function (id, callback) { 1240 | 1241 | callback(null, apps[id]); 1242 | }, 1243 | ticket: { 1244 | iron: Iron.defaults 1245 | } 1246 | }; 1247 | 1248 | const grant = { 1249 | id: 'a1b2c3d4e5f6g7h8i9j0', 1250 | app: appTicket.app, 1251 | user: 'john', 1252 | exp: Oz.hawk.utils.now() + 60000 1253 | }; 1254 | 1255 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 1256 | 1257 | expect(err).to.not.exist(); 1258 | 1259 | options.loadGrantFunc = function (id, callback) { 1260 | 1261 | callback(null, null); 1262 | }; 1263 | 1264 | const payload = { rsvp: rsvp }; 1265 | 1266 | const req = { 1267 | method: 'POST', 1268 | url: '/oz/rsvp', 1269 | headers: { 1270 | host: 'example.com', 1271 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 1272 | } 1273 | }; 1274 | 1275 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 1276 | 1277 | expect(err).to.exist(); 1278 | expect(err.message).to.equal('Invalid grant'); 1279 | done(); 1280 | }); 1281 | }); 1282 | }); 1283 | 1284 | it('fails on invalid rsvp (grant app mismatch)', (done) => { 1285 | 1286 | const options = { 1287 | encryptionPassword: encryptionPassword, 1288 | loadAppFunc: function (id, callback) { 1289 | 1290 | callback(null, apps[id]); 1291 | }, 1292 | ticket: { 1293 | iron: Iron.defaults 1294 | } 1295 | }; 1296 | 1297 | const grant = { 1298 | id: 'a1b2c3d4e5f6g7h8i9j0', 1299 | app: appTicket.app, 1300 | user: 'john', 1301 | exp: Oz.hawk.utils.now() + 60000 1302 | }; 1303 | 1304 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 1305 | 1306 | expect(err).to.not.exist(); 1307 | 1308 | options.loadGrantFunc = function (id, callback) { 1309 | 1310 | grant.app = apps.network.id; 1311 | callback(null, grant); 1312 | }; 1313 | 1314 | const payload = { rsvp: rsvp }; 1315 | 1316 | const req = { 1317 | method: 'POST', 1318 | url: '/oz/rsvp', 1319 | headers: { 1320 | host: 'example.com', 1321 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 1322 | } 1323 | }; 1324 | 1325 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 1326 | 1327 | expect(err).to.exist(); 1328 | expect(err.message).to.equal('Invalid grant'); 1329 | done(); 1330 | }); 1331 | }); 1332 | }); 1333 | 1334 | it('fails on invalid rsvp (grant missing exp)', (done) => { 1335 | 1336 | const options = { 1337 | encryptionPassword: encryptionPassword, 1338 | loadAppFunc: function (id, callback) { 1339 | 1340 | callback(null, apps[id]); 1341 | }, 1342 | ticket: { 1343 | iron: Iron.defaults 1344 | } 1345 | }; 1346 | 1347 | const grant = { 1348 | id: 'a1b2c3d4e5f6g7h8i9j0', 1349 | app: appTicket.app, 1350 | user: 'john', 1351 | exp: Oz.hawk.utils.now() + 60000 1352 | }; 1353 | 1354 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 1355 | 1356 | expect(err).to.not.exist(); 1357 | 1358 | options.loadGrantFunc = function (id, callback) { 1359 | 1360 | delete grant.exp; 1361 | callback(null, grant); 1362 | }; 1363 | 1364 | const payload = { rsvp: rsvp }; 1365 | 1366 | const req = { 1367 | method: 'POST', 1368 | url: '/oz/rsvp', 1369 | headers: { 1370 | host: 'example.com', 1371 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 1372 | } 1373 | }; 1374 | 1375 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 1376 | 1377 | expect(err).to.exist(); 1378 | expect(err.message).to.equal('Invalid grant'); 1379 | done(); 1380 | }); 1381 | }); 1382 | }); 1383 | 1384 | it('fails on invalid rsvp (grant error)', (done) => { 1385 | 1386 | const options = { 1387 | encryptionPassword: encryptionPassword, 1388 | loadAppFunc: function (id, callback) { 1389 | 1390 | callback(null, apps[id]); 1391 | }, 1392 | ticket: { 1393 | iron: Iron.defaults 1394 | } 1395 | }; 1396 | 1397 | const grant = { 1398 | id: 'a1b2c3d4e5f6g7h8i9j0', 1399 | app: appTicket.app, 1400 | user: 'john', 1401 | exp: Oz.hawk.utils.now() + 60000 1402 | }; 1403 | 1404 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 1405 | 1406 | expect(err).to.not.exist(); 1407 | 1408 | options.loadGrantFunc = function (id, callback) { 1409 | 1410 | callback(new Error('boom')); 1411 | }; 1412 | 1413 | const payload = { rsvp: rsvp }; 1414 | 1415 | const req = { 1416 | method: 'POST', 1417 | url: '/oz/rsvp', 1418 | headers: { 1419 | host: 'example.com', 1420 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 1421 | } 1422 | }; 1423 | 1424 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 1425 | 1426 | expect(err).to.exist(); 1427 | expect(err.message).to.equal('boom'); 1428 | done(); 1429 | }); 1430 | }); 1431 | }); 1432 | 1433 | it('fails on invalid rsvp (app error)', (done) => { 1434 | 1435 | const options = { 1436 | encryptionPassword: encryptionPassword, 1437 | loadAppFunc: function (id, callback) { 1438 | 1439 | callback(null, apps[id]); 1440 | }, 1441 | ticket: { 1442 | iron: Iron.defaults 1443 | } 1444 | }; 1445 | 1446 | const grant = { 1447 | id: 'a1b2c3d4e5f6g7h8i9j0', 1448 | app: appTicket.app, 1449 | user: 'john', 1450 | exp: Oz.hawk.utils.now() + 60000 1451 | }; 1452 | 1453 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 1454 | 1455 | expect(err).to.not.exist(); 1456 | 1457 | options.loadGrantFunc = function (id, callback) { 1458 | 1459 | callback(null, grant); 1460 | }; 1461 | 1462 | const payload = { rsvp: rsvp }; 1463 | 1464 | const req = { 1465 | method: 'POST', 1466 | url: '/oz/rsvp', 1467 | headers: { 1468 | host: 'example.com', 1469 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 1470 | } 1471 | }; 1472 | 1473 | options.loadAppFunc = function (id, callback) { 1474 | 1475 | return callback(new Error('nope')); 1476 | }; 1477 | 1478 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 1479 | 1480 | expect(err).to.exist(); 1481 | expect(err.message).to.equal('nope'); 1482 | done(); 1483 | }); 1484 | }); 1485 | }); 1486 | 1487 | it('fails on invalid rsvp (invalid app)', (done) => { 1488 | 1489 | const options = { 1490 | encryptionPassword: encryptionPassword, 1491 | loadAppFunc: function (id, callback) { 1492 | 1493 | callback(null, apps[id]); 1494 | } 1495 | }; 1496 | 1497 | const grant = { 1498 | id: 'a1b2c3d4e5f6g7h8i9j0', 1499 | app: appTicket.app, 1500 | user: 'john', 1501 | exp: Oz.hawk.utils.now() + 60000 1502 | }; 1503 | 1504 | Oz.ticket.rsvp(apps.social, grant, encryptionPassword, {}, (err, rsvp) => { 1505 | 1506 | expect(err).to.not.exist(); 1507 | 1508 | options.loadGrantFunc = function (id, callback) { 1509 | 1510 | callback(null, grant); 1511 | }; 1512 | 1513 | const payload = { rsvp: rsvp }; 1514 | 1515 | const req = { 1516 | method: 'POST', 1517 | url: '/oz/rsvp', 1518 | headers: { 1519 | host: 'example.com', 1520 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).field 1521 | } 1522 | }; 1523 | 1524 | options.loadAppFunc = function (id, callback) { 1525 | 1526 | callback(null, null); 1527 | }; 1528 | 1529 | Oz.endpoints.rsvp(req, payload, options, (err, ticket) => { 1530 | 1531 | expect(err).to.exist(); 1532 | expect(err.message).to.equal('Invalid application'); 1533 | done(); 1534 | }); 1535 | }); 1536 | }); 1537 | }); 1538 | }); 1539 | --------------------------------------------------------------------------------