├── .npmignore ├── images └── oz.png ├── .gitignore ├── .travis.yml ├── lib ├── index.js ├── scope.js ├── server.js ├── client.js ├── endpoints.js └── ticket.js ├── package.json ├── LICENSE ├── test ├── scope.js ├── index.js ├── server.js ├── ticket.js ├── client.js └── endpoints.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !lib/** 3 | !.npmignore 4 | -------------------------------------------------------------------------------- /images/oz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outmoded/oz/HEAD/images/oz.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/package-lock.json 3 | 4 | coverage.* 5 | 6 | **/.DS_Store 7 | **/._* 8 | 9 | **/*.pem 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "8" 5 | - "10" 6 | - "11" 7 | - "node" 8 | 9 | sudo: false 10 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Export sub-modules 4 | 5 | exports.client = require('./client'); 6 | 7 | exports.endpoints = require('./endpoints'); 8 | 9 | exports.hawk = require('hawk'); 10 | 11 | exports.scope = require('./scope'); 12 | 13 | exports.server = require('./server'); 14 | 15 | exports.ticket = require('./ticket'); 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oz", 3 | "description": "Web Authorization Protocol", 4 | "version": "5.0.3", 5 | "repository": "git://github.com/hueniverse/oz", 6 | "main": "lib/index.js", 7 | "keywords": [ 8 | "http", 9 | "authorization", 10 | "oz", 11 | "hawk" 12 | ], 13 | "dependencies": { 14 | "boom": "7.x.x", 15 | "cryptiles": "4.x.x", 16 | "hawk": "7.x.x", 17 | "hoek": "6.x.x", 18 | "iron": "5.x.x", 19 | "joi": "14.x.x", 20 | "wreck": "14.x.x" 21 | }, 22 | "devDependencies": { 23 | "code": "5.x.x", 24 | "lab": "17.x.x" 25 | }, 26 | "scripts": { 27 | "test": "lab -a code -t 100 -L", 28 | "test-cov-html": "lab -a code -r html -o coverage.html" 29 | }, 30 | "license": "BSD-3-Clause" 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2018, Eran Hammer and Project 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 | -------------------------------------------------------------------------------- /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 | throw Boom.internal('null scope'); 20 | } 21 | 22 | if (scope instanceof Array === false) { 23 | throw 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 | throw Boom.badRequest('scope includes null or empty string value'); 30 | } 31 | 32 | if (typeof scope[i] !== 'string') { 33 | throw Boom.badRequest('scope item is not a string'); 34 | } 35 | 36 | if (hash[scope[i]]) { 37 | throw Boom.badRequest('scope includes duplicated item'); 38 | } 39 | 40 | hash[scope[i]] = true; 41 | } 42 | }; 43 | 44 | 45 | // Check is one scope is a subset of another 46 | 47 | exports.isSubset = function (scope, subset) { 48 | 49 | if (!scope) { 50 | return false; 51 | } 52 | 53 | if (scope.length < subset.length) { 54 | return false; 55 | } 56 | 57 | const common = Hoek.intersect(scope, subset); 58 | return common.length === subset.length; 59 | }; 60 | 61 | 62 | // Check is two scope arrays are the same 63 | 64 | exports.isEqual = function (one, two) { 65 | 66 | if (one === two) { 67 | return true; 68 | } 69 | 70 | if (!one || 71 | !two) { 72 | 73 | return false; 74 | } 75 | 76 | if (one.length !== two.length) { 77 | return false; 78 | } 79 | 80 | const common = Hoek.intersect(one, two); 81 | return common.length === one.length; 82 | }; 83 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Hoek = require('hoek'); 6 | const Hawk = require('hawk'); 7 | 8 | const Ticket = require('./ticket'); 9 | 10 | 11 | // Declare internals 12 | 13 | const internals = {}; 14 | 15 | 16 | // Validate an incoming request 17 | 18 | exports.authenticate = function (req, encryptionPassword, options) { 19 | 20 | return exports._authenticate(req, encryptionPassword, true, options); 21 | }; 22 | 23 | 24 | exports._authenticate = async function (req, encryptionPassword, checkExpiration, options) { 25 | 26 | options = options || {}; 27 | 28 | Hoek.assert(encryptionPassword, 'Invalid encryption password'); 29 | 30 | // Hawk credentials lookup method 31 | 32 | const credentialsFunc = async function (id) { 33 | 34 | // Parse ticket id 35 | 36 | const ticket = await Ticket.parse(id, encryptionPassword, options.ticket); 37 | 38 | // Check expiration 39 | 40 | if (checkExpiration && 41 | ticket.exp <= Hawk.utils.now()) { 42 | 43 | const error = Hawk.utils.unauthorized('Expired ticket'); 44 | error.output.payload.expired = true; 45 | throw error; 46 | } 47 | 48 | return ticket; 49 | }; 50 | 51 | // Hawk authentication 52 | 53 | const { credentials, artifacts } = await Hawk.server.authenticate(req, credentialsFunc, options.hawk); 54 | 55 | // Check application 56 | 57 | if (credentials.app !== artifacts.app) { 58 | throw Hawk.utils.unauthorized('Mismatching application id'); 59 | } 60 | 61 | if ((credentials.dlg || artifacts.dlg) && 62 | credentials.dlg !== artifacts.dlg) { 63 | 64 | throw Hawk.utils.unauthorized('Mismatching delegated application id'); 65 | } 66 | 67 | // Return result 68 | 69 | return { ticket: credentials, artifacts }; 70 | }; 71 | -------------------------------------------------------------------------------- /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', () => { 28 | 29 | const scope = ['a', 'b', 'c']; 30 | expect(() => Oz.scope.validate(scope)).to.not.throw(); 31 | }); 32 | 33 | it('should return error when scope is null', () => { 34 | 35 | expect(() => Oz.scope.validate(null)).to.throw(); 36 | }); 37 | 38 | it('should return error when scope is not an array', () => { 39 | 40 | expect(() => Oz.scope.validate({})).to.throw(); 41 | }); 42 | 43 | it('should return error when scope contains non-string values', () => { 44 | 45 | const scope = ['a', 'b', 1]; 46 | expect(() => Oz.scope.validate(scope)).to.throw(); 47 | }); 48 | 49 | it('should return error when scope contains duplicates', () => { 50 | 51 | const scope = ['a', 'b', 'b']; 52 | expect(() => Oz.scope.validate(scope)).to.throw(); 53 | }); 54 | 55 | it('should return error when scope contains empty strings', () => { 56 | 57 | const scope = ['a', 'b', '']; 58 | expect(() => Oz.scope.validate(scope)).to.throw(); 59 | }); 60 | }); 61 | 62 | describe('isSubset()', () => { 63 | 64 | it('should return true when scope is a subset', () => { 65 | 66 | const scope = ['a', 'b', 'c']; 67 | const subset = ['a', 'c']; 68 | const isSubset = Oz.scope.isSubset(scope, subset); 69 | expect(isSubset).to.equal(true); 70 | }); 71 | 72 | it('should return false when scope is not a subset', () => { 73 | 74 | const scope = ['a']; 75 | const subset = ['a', 'c']; 76 | const isSubset = Oz.scope.isSubset(scope, subset); 77 | expect(isSubset).to.equal(false); 78 | }); 79 | 80 | it('should return false when scope is not a subset but equal length', () => { 81 | 82 | const scope = ['a', 'b']; 83 | const subset = ['a', 'c']; 84 | const isSubset = Oz.scope.isSubset(scope, subset); 85 | expect(isSubset).to.equal(false); 86 | }); 87 | 88 | it('should return false when scope is not a subset due to duplicates', () => { 89 | 90 | const scope = ['a', 'c', 'c', 'd']; 91 | const subset = ['a', 'c', 'c']; 92 | const isSubset = Oz.scope.isSubset(scope, subset); 93 | expect(isSubset).to.equal(false); 94 | }); 95 | }); 96 | 97 | describe('isEqual()', () => { 98 | 99 | it('compares scopes', () => { 100 | 101 | const scope = ['a', 'b', 'c']; 102 | expect(Oz.scope.isEqual(null, null)).to.equal(true); 103 | expect(Oz.scope.isEqual(scope, scope)).to.equal(true); 104 | expect(Oz.scope.isEqual(null, scope)).to.equal(false); 105 | expect(Oz.scope.isEqual(scope, null)).to.equal(false); 106 | expect(Oz.scope.isEqual(scope, [])).to.equal(false); 107 | expect(Oz.scope.isEqual([], scope)).to.equal(false); 108 | expect(Oz.scope.isEqual(scope, ['a', 'b', 'c'])).to.equal(true); 109 | expect(Oz.scope.isEqual(scope, ['a', 'c', 'd'])).to.equal(false); 110 | expect(Oz.scope.isEqual(['a', 'b', 'c'], scope)).to.equal(true); 111 | expect(Oz.scope.isEqual(['a', 'c', 'd'], scope)).to.equal(false); 112 | }); 113 | }); 114 | }); 115 | 116 | 117 | -------------------------------------------------------------------------------- /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 | options = options || {}; 28 | 29 | const settings = Object.assign({}, options); // Shallow cloned 30 | settings.credentials = ticket; 31 | settings.app = ticket.app; 32 | settings.dlg = ticket.dlg; 33 | 34 | return Hawk.client.header(uri, method, settings); 35 | }; 36 | 37 | 38 | exports.Connection = internals.Connection = class { 39 | 40 | constructor(options) { 41 | 42 | this.settings = Hoek.applyToDefaults(internals.defaults, options); 43 | this._appTicket = null; 44 | } 45 | 46 | async request(path, ticket, options) { 47 | 48 | options = options || {}; 49 | 50 | const method = options.method || 'GET'; 51 | const { code, result } = await this._request(method, path, options.payload, ticket); 52 | 53 | if (code !== 401 || 54 | !result || 55 | !result.expired) { 56 | 57 | return { code, result, ticket }; 58 | } 59 | 60 | // Try to reissue ticket 61 | 62 | const reissued = await this.reissue(ticket); 63 | 64 | // Try resource again and pass back the ticket reissued (when not app) 65 | 66 | const { code: rCode, result: rResult } = await this._request(method, path, options.payload, reissued); 67 | return { result: rResult, code: rCode, ticket: reissued }; 68 | } 69 | 70 | async app(path, options) { 71 | 72 | if (!this._appTicket) { 73 | await this._requestAppTicket(); 74 | } 75 | 76 | const response = await this.request(path, this._appTicket, options); 77 | this._appTicket = response.ticket; // In case ticket was refreshed 78 | return response; 79 | } 80 | 81 | async reissue(ticket) { 82 | 83 | const { code, result: reissued } = await this._request('POST', this.settings.endpoints.reissue, null, ticket); 84 | 85 | if (code !== 200) { 86 | throw Boom.internal(reissued.message); 87 | } 88 | 89 | return reissued; 90 | } 91 | 92 | async _request(method, path, payload, ticket) { 93 | 94 | const body = (payload !== null ? JSON.stringify(payload) : null); 95 | const uri = this.settings.uri + path; 96 | const headers = {}; 97 | 98 | if (typeof payload === 'object') { 99 | headers['content-type'] = 'application/json'; 100 | } 101 | 102 | const { header, artifacts } = exports.header(uri, method, ticket); 103 | headers.Authorization = header; 104 | 105 | const response = await Wreck.request(method, uri, { headers, payload: body }); 106 | const result = await Wreck.read(response, { json: true }); 107 | 108 | await Hawk.client.authenticate(response, ticket, artifacts); 109 | return { code: response.statusCode, result }; 110 | } 111 | 112 | async _requestAppTicket() { 113 | 114 | const uri = this.settings.uri + this.settings.endpoints.app; 115 | const { header } = exports.header(uri, 'POST', this.settings.credentials); 116 | 117 | const response = await Wreck.request('POST', uri, { headers: { Authorization: header } }); 118 | const ticket = await Wreck.read(response, { json: true }); // Always read to drain the stream 119 | 120 | if (response.statusCode !== 200) { 121 | throw Boom.internal('Client registration failed with unexpected response', { code: response.statusCode, payload: ticket }); 122 | } 123 | 124 | this._appTicket = ticket; 125 | } 126 | }; 127 | -------------------------------------------------------------------------------- /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 { describe, it } = exports.lab = Lab.script(); 18 | const expect = Code.expect; 19 | 20 | 21 | describe('Oz', () => { 22 | 23 | it('runs a full authorization flow', async () => { 24 | 25 | const encryptionPassword = 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough'; 26 | 27 | const apps = { 28 | social: { 29 | id: 'social', 30 | scope: ['a', 'b', 'c'], 31 | key: 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn', 32 | algorithm: 'sha256', 33 | delegate: true 34 | }, 35 | network: { 36 | id: 'network', 37 | scope: ['b', 'x'], 38 | key: 'witf745itwn7ey4otnw7eyi4t7syeir7bytise7rbyi', 39 | algorithm: 'sha256' 40 | } 41 | }; 42 | 43 | // The app requests an app ticket using Oz.hawk authentication 44 | 45 | let req = { 46 | method: 'POST', 47 | url: '/oz/app', 48 | headers: { 49 | host: 'example.com', 50 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).header 51 | } 52 | }; 53 | 54 | const options = { 55 | encryptionPassword, 56 | loadAppFunc: (id) => apps[id] 57 | }; 58 | 59 | const appTicket = await Oz.endpoints.app(req, null, options); 60 | 61 | // The app refreshes its own ticket 62 | 63 | req = { 64 | method: 'POST', 65 | url: '/oz/reissue', 66 | headers: { 67 | host: 'example.com', 68 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).header 69 | } 70 | }; 71 | 72 | const reAppTicket = await Oz.endpoints.reissue(req, {}, options); 73 | 74 | // The user is redirected to the server, logs in, and grant app access, resulting in an rsvp 75 | 76 | const grant = { 77 | id: 'a1b2c3d4e5f6g7h8i9j0', 78 | app: reAppTicket.app, 79 | user: 'john', 80 | exp: Oz.hawk.utils.now() + 60000 81 | }; 82 | 83 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 84 | 85 | // After granting app access, the user returns to the app with the rsvp 86 | 87 | options.loadGrantFunc = (id) => { 88 | 89 | const ext = { 90 | public: 'everybody knows', 91 | private: 'the the dice are loaded' 92 | }; 93 | 94 | return { grant, ext }; 95 | }; 96 | 97 | // The app exchanges the rsvp for a ticket 98 | 99 | let payload = { rsvp }; 100 | 101 | req = { 102 | method: 'POST', 103 | url: '/oz/rsvp', 104 | headers: { 105 | host: 'example.com', 106 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', reAppTicket).header 107 | } 108 | }; 109 | 110 | const ticket = await Oz.endpoints.rsvp(req, payload, options); 111 | 112 | // The app reissues the ticket with delegation to another app 113 | 114 | payload = { 115 | issueTo: apps.network.id, 116 | scope: ['a'] 117 | }; 118 | 119 | req = { 120 | method: 'POST', 121 | url: '/oz/reissue', 122 | headers: { 123 | host: 'example.com', 124 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).header 125 | } 126 | }; 127 | 128 | const delegatedTicket = await Oz.endpoints.reissue(req, payload, options); 129 | 130 | // The other app reissues their ticket 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', delegatedTicket).header 138 | } 139 | }; 140 | 141 | await expect(Oz.endpoints.reissue(req, {}, options)).to.not.reject(); 142 | }); 143 | }); 144 | 145 | 146 | -------------------------------------------------------------------------------- /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 | 10 | const Ticket = require('./ticket'); 11 | const Server = require('./server'); 12 | 13 | 14 | // Declare internals 15 | 16 | const internals = { 17 | schema: {} 18 | }; 19 | 20 | 21 | /* 22 | const options = { 23 | encryptionPassword: 'f84rf84r3hjdf8hw38hr', 24 | hawk: {}, 25 | ticket: {}, 26 | 27 | loadAppFunc: async function (id) { return app; }, 28 | loadGrantFunc: async function (id) { return { grant, ext }; } 29 | }; 30 | */ 31 | 32 | // Request an application ticket using Hawk authentication 33 | 34 | exports.app = async function (req, payload, options) { 35 | 36 | const { credentials } = await Hawk.server.authenticate(req, options.loadAppFunc, options.hawk); 37 | return Ticket.issue(credentials, null, options.encryptionPassword, options.ticket); 38 | }; 39 | 40 | 41 | // Request a ticket reissue using the authenticating ticket 42 | 43 | internals.schema.reissue = Joi.object({ 44 | issueTo: Joi.string(), 45 | scope: Joi.array().items(Joi.string()) 46 | }); 47 | 48 | 49 | exports.reissue = async function (req, payload, options) { 50 | 51 | payload = payload || {}; 52 | 53 | await internals.validate('reissue', payload); 54 | 55 | const { ticket } = await Server._authenticate(req, options.encryptionPassword, false, options); 56 | 57 | // Load ticket 58 | 59 | const app = await options.loadAppFunc(ticket.app); 60 | if (!app) { 61 | throw Hawk.utils.unauthorized('Invalid application'); 62 | } 63 | 64 | if (payload.issueTo && 65 | !app.delegate) { 66 | 67 | throw Boom.forbidden('Application has no delegation rights'); 68 | } 69 | 70 | 71 | const reissue = (grant, ext) => { 72 | 73 | const ticketOptions = Object.assign({}, options.ticket); // Shallow cloned 74 | 75 | if (ext) { 76 | ticketOptions.ext = ext; 77 | } 78 | 79 | if (payload.issueTo) { 80 | ticketOptions.issueTo = payload.issueTo; 81 | } 82 | 83 | if (payload.scope) { 84 | ticketOptions.scope = payload.scope; 85 | } 86 | 87 | return Ticket.reissue(ticket, grant, options.encryptionPassword, ticketOptions); 88 | }; 89 | 90 | // Application ticket 91 | 92 | if (!ticket.grant) { 93 | return reissue(); 94 | } 95 | 96 | // User ticket 97 | 98 | const { grant, ext } = await options.loadGrantFunc(ticket.grant); 99 | 100 | if (!grant || 101 | (grant.app !== ticket.app && grant.app !== ticket.dlg) || 102 | grant.user !== ticket.user || 103 | !grant.exp || 104 | grant.exp <= Hawk.utils.now()) { 105 | 106 | throw Hawk.utils.unauthorized('Invalid grant'); 107 | } 108 | 109 | return reissue(grant, ext); 110 | }; 111 | 112 | 113 | internals.schema.rsvp = Joi.object({ 114 | rsvp: Joi.string().required() 115 | }); 116 | 117 | 118 | exports.rsvp = async function (req, payload, options) { 119 | 120 | if (!payload) { 121 | throw Boom.badRequest('Missing required payload'); 122 | } 123 | 124 | await internals.validate('rsvp', payload); 125 | 126 | const { ticket } = await Server.authenticate(req, options.encryptionPassword, options); 127 | 128 | if (ticket.user) { 129 | throw Hawk.utils.unauthorized('User ticket cannot be used on an application endpoint'); 130 | } 131 | 132 | const envelope = await Ticket.parse(payload.rsvp, options.encryptionPassword, options.ticket); 133 | 134 | if (envelope.app !== ticket.app) { 135 | throw Boom.forbidden('Mismatching ticket and rsvp apps'); 136 | } 137 | 138 | const now = Hawk.utils.now(); 139 | 140 | if (envelope.exp <= now) { 141 | throw Boom.forbidden('Expired rsvp'); 142 | } 143 | 144 | const grantResult = await options.loadGrantFunc(envelope.grant); 145 | if (!grantResult) { 146 | throw Boom.forbidden('Invalid grant'); 147 | } 148 | 149 | const { grant, ext } = grantResult; 150 | if (!grant || 151 | grant.app !== ticket.app || 152 | !grant.exp || 153 | grant.exp <= now) { 154 | 155 | throw Boom.forbidden('Invalid grant'); 156 | } 157 | 158 | const app = await options.loadAppFunc(grant.app); 159 | if (!app) { 160 | throw Boom.forbidden('Invalid application'); 161 | } 162 | 163 | let ticketOptions = options.ticket || {}; 164 | if (ext) { 165 | ticketOptions = Object.assign({}, ticketOptions); // Shallow Cloned 166 | ticketOptions.ext = ext; 167 | } 168 | 169 | return Ticket.issue(app, grant, options.encryptionPassword, ticketOptions); 170 | }; 171 | 172 | 173 | internals.validate = async function (type, payload) { 174 | 175 | try { 176 | await Joi.validate(payload, internals.schema[type]); 177 | } 178 | catch (err) { 179 | throw Boom.badRequest(`Invalid request payload: ${Hoek.escapeHtml(err.details[0].message.replace(/"/g, ''))}`, err); 180 | } 181 | }; 182 | -------------------------------------------------------------------------------- /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 { describe, it } = exports.lab = Lab.script(); 18 | const expect = Code.expect; 19 | 20 | 21 | describe('Server', () => { 22 | 23 | describe('authenticate()', () => { 24 | 25 | it('throws an error on missing password', async () => { 26 | 27 | await expect(Oz.server.authenticate(null, null)).to.reject('Invalid encryption password'); 28 | }); 29 | 30 | const encryptionPassword = 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough'; 31 | 32 | const app = { 33 | id: '123' 34 | }; 35 | 36 | it('authenticates a request', async () => { 37 | 38 | const grant = { 39 | id: 's81u29n1812', 40 | user: '456', 41 | exp: Oz.hawk.utils.now() + 5000, 42 | scope: ['a', 'b'] 43 | }; 44 | 45 | const envelope = await Oz.ticket.issue(app, grant, encryptionPassword); 46 | 47 | const req = { 48 | method: 'POST', 49 | url: '/oz/rsvp', 50 | headers: { 51 | host: 'example.com', 52 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).header 53 | } 54 | }; 55 | 56 | await expect(Oz.server.authenticate(req, encryptionPassword)).to.not.reject(); 57 | }); 58 | 59 | it('authenticates a request (hawk options)', async () => { 60 | 61 | const grant = { 62 | id: 's81u29n1812', 63 | user: '456', 64 | exp: Oz.hawk.utils.now() + 5000, 65 | scope: ['a', 'b'] 66 | }; 67 | 68 | const envelope = await Oz.ticket.issue(app, grant, encryptionPassword); 69 | 70 | const req = { 71 | method: 'POST', 72 | url: '/oz/rsvp', 73 | headers: { 74 | hostx1: 'example.com', 75 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).header 76 | } 77 | }; 78 | 79 | await expect(Oz.server.authenticate(req, encryptionPassword, { hawk: { hostHeaderName: 'hostx1' } })).to.not.reject(); 80 | }); 81 | 82 | it('fails to authenticate a request with bad password', async () => { 83 | 84 | const grant = { 85 | id: 's81u29n1812', 86 | user: '456', 87 | exp: Oz.hawk.utils.now() + 5000, 88 | scope: ['a', 'b'] 89 | }; 90 | 91 | const envelope = await Oz.ticket.issue(app, grant, encryptionPassword); 92 | 93 | const req = { 94 | method: 'POST', 95 | url: '/oz/rsvp', 96 | headers: { 97 | host: 'example.com', 98 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).header 99 | } 100 | }; 101 | 102 | await expect(Oz.server.authenticate(req, 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough_x')).to.reject('Bad hmac value'); 103 | }); 104 | 105 | it('fails to authenticate a request with expired ticket', async () => { 106 | 107 | const grant = { 108 | id: 's81u29n1812', 109 | user: '456', 110 | exp: Oz.hawk.utils.now() - 5000, 111 | scope: ['a', 'b'] 112 | }; 113 | 114 | const envelope = await Oz.ticket.issue(app, grant, encryptionPassword); 115 | 116 | const req = { 117 | method: 'POST', 118 | url: '/oz/rsvp', 119 | headers: { 120 | host: 'example.com', 121 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).header 122 | } 123 | }; 124 | 125 | const err = await expect(Oz.server.authenticate(req, encryptionPassword)).to.reject('Expired ticket'); 126 | expect(err.output.payload.expired).to.be.true(); 127 | }); 128 | 129 | it('fails to authenticate a request with mismatching app id', async () => { 130 | 131 | const grant = { 132 | id: 's81u29n1812', 133 | user: '456', 134 | exp: Oz.hawk.utils.now() + 5000, 135 | scope: ['a', 'b'] 136 | }; 137 | 138 | const envelope = await Oz.ticket.issue(app, grant, encryptionPassword); 139 | 140 | envelope.app = '567'; 141 | const req = { 142 | method: 'POST', 143 | url: '/oz/rsvp', 144 | headers: { 145 | host: 'example.com', 146 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).header 147 | } 148 | }; 149 | 150 | await expect(Oz.server.authenticate(req, encryptionPassword)).to.reject('Mismatching application id'); 151 | }); 152 | 153 | it('fails to authenticate a request with mismatching dlg id', async () => { 154 | 155 | const grant = { 156 | id: 's81u29n1812', 157 | user: '456', 158 | exp: Oz.hawk.utils.now() + 5000, 159 | scope: ['a', 'b'] 160 | }; 161 | 162 | const envelope = await Oz.ticket.issue(app, grant, encryptionPassword); 163 | 164 | envelope.dlg = '567'; 165 | const req = { 166 | method: 'POST', 167 | url: '/oz/rsvp', 168 | headers: { 169 | host: 'example.com', 170 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', envelope).header 171 | } 172 | }; 173 | 174 | await expect(Oz.server.authenticate(req, encryptionPassword)).to.reject('Mismatching delegated application id'); 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /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 Iron = require('iron'); 9 | 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) { 57 | 58 | options = options || {}; 59 | 60 | if (!app || !app.id) { 61 | throw Boom.internal('Invalid application object'); 62 | } 63 | 64 | if (grant && (!grant.id || !grant.user || !grant.exp)) { 65 | throw Boom.internal('Invalid grant object'); 66 | } 67 | 68 | if (!encryptionPassword) { 69 | throw Boom.internal('Invalid encryption password'); 70 | } 71 | 72 | const scope = (grant && grant.scope) || app.scope || []; 73 | Scope.validate(scope); 74 | 75 | if (grant && 76 | grant.scope && 77 | app.scope && 78 | !Scope.isSubset(app.scope, grant.scope)) { 79 | 80 | throw Boom.internal('Grant scope is not a subset of the application scope'); 81 | } 82 | 83 | // Construct ticket 84 | 85 | let exp = (Hawk.utils.now() + (options.ttl || internals.defaults.ticketTTL)); 86 | if (grant) { 87 | exp = Math.min(exp, grant.exp); 88 | } 89 | 90 | const ticket = { 91 | exp, 92 | app: app.id, 93 | scope 94 | }; 95 | 96 | if (grant) { 97 | ticket.grant = grant.id; 98 | ticket.user = grant.user; 99 | } 100 | 101 | if (options.delegate === false) { // Defaults to true 102 | ticket.delegate = false; 103 | } 104 | 105 | return exports.generate(ticket, encryptionPassword, options); 106 | }; 107 | 108 | 109 | // Reissue ticket 110 | 111 | /* 112 | const grant = { 113 | id: 'd832d9283hd9823dh', // Persistent identifier used to issue additional tickets or revoke access 114 | user: '456', // User id 115 | exp: 1352535473414, // Grant expiration 116 | scope: ['b'] // Grant scope 117 | }; 118 | 119 | const options = { 120 | ttl: 60 * 1000, // 1 min 121 | delegate: false, // Ticket-specific delegation permission (default to true) 122 | scope: ['b'], // Ticket scope (must be equal or lesser than parent) 123 | issueTo: '123', // Delegated to application id 124 | ext: { // Server-specific extension data 125 | public: { // Included in the plain ticket 126 | tos: '0.0.1' 127 | }, 128 | private: { // Included in the encoded ticket 129 | x: 1 130 | } 131 | }, 132 | iron: {} // Override Iron defaults 133 | keyBytes: 32, // Hawk key length 134 | hmacAlgorithm: 'sha256' // Hawk algorithm 135 | }; 136 | */ 137 | 138 | exports.reissue = function (parentTicket, grant, encryptionPassword, options) { 139 | 140 | options = options || {}; 141 | 142 | if (!parentTicket) { 143 | throw Boom.internal('Invalid parent ticket object'); 144 | } 145 | 146 | if (!encryptionPassword) { 147 | throw Boom.internal('Invalid encryption password'); 148 | } 149 | 150 | if (parentTicket.scope) { 151 | Scope.validate(parentTicket.scope); 152 | } 153 | 154 | if (options.scope) { 155 | Scope.validate(options.scope); 156 | 157 | if (!Scope.isSubset(parentTicket.scope, options.scope)) { 158 | throw Boom.forbidden('New scope is not a subset of the parent ticket scope'); 159 | } 160 | } 161 | 162 | if (options.delegate && 163 | parentTicket.delegate === false) { 164 | 165 | throw Boom.forbidden('Cannot override ticket delegate restriction'); 166 | } 167 | 168 | if (options.issueTo) { 169 | if (parentTicket.dlg) { 170 | throw Boom.badRequest('Cannot re-delegate'); 171 | } 172 | 173 | if (parentTicket.delegate === false) { // Defaults to true 174 | throw Boom.forbidden('Ticket does not allow delegation'); 175 | } 176 | } 177 | 178 | if (grant && (!grant.id || !grant.user || !grant.exp)) { 179 | throw Boom.internal('Invalid grant object'); 180 | } 181 | 182 | if (grant || parentTicket.grant) { 183 | if (!grant || 184 | !parentTicket.grant || 185 | parentTicket.grant !== grant.id) { 186 | 187 | throw Boom.internal('Parent ticket grant does not match options.grant'); 188 | } 189 | } 190 | 191 | // Construct ticket 192 | 193 | let exp = (Hawk.utils.now() + (options.ttl || internals.defaults.ticketTTL)); 194 | if (grant) { 195 | exp = Math.min(exp, grant.exp); 196 | } 197 | 198 | const ticket = { 199 | exp, 200 | app: options.issueTo || parentTicket.app, 201 | scope: options.scope || parentTicket.scope 202 | }; 203 | 204 | if (!options.ext && 205 | parentTicket.ext) { 206 | 207 | options = Object.assign({}, options); // Shallow cloned 208 | options.ext = parentTicket.ext; 209 | } 210 | 211 | if (grant) { 212 | ticket.grant = grant.id; 213 | ticket.user = grant.user; 214 | } 215 | 216 | if (options.issueTo) { 217 | ticket.dlg = parentTicket.app; 218 | } 219 | else if (parentTicket.dlg) { 220 | ticket.dlg = parentTicket.dlg; 221 | } 222 | 223 | if (options.delegate === false || // Defaults to true 224 | parentTicket.delegate === false) { 225 | 226 | ticket.delegate = false; 227 | } 228 | 229 | return exports.generate(ticket, encryptionPassword, options); 230 | }; 231 | 232 | 233 | /* 234 | // The requesting application 235 | 236 | const app = { 237 | id: '123', // Application id 238 | }; 239 | 240 | // The resource owner 241 | 242 | const grant = { 243 | id: 'd832d9283hd9823dh' // Persistent identifier used to issue additional tickets or revoke access 244 | }; 245 | 246 | const options = { 247 | ttl: 1 * 60 * 10000, // Rsvp TTL 248 | iron: {} // Override Iron defaults 249 | }; 250 | */ 251 | 252 | exports.rsvp = function (app, grant, encryptionPassword, options) { 253 | 254 | options = options || {}; 255 | 256 | if (!app || !app.id) { 257 | throw Boom.internal('Invalid application object'); 258 | } 259 | 260 | if (!grant || !grant.id) { 261 | throw Boom.internal('Invalid grant object'); 262 | } 263 | 264 | if (!encryptionPassword) { 265 | throw Boom.internal('Invalid encryption password'); 266 | } 267 | 268 | options.ttl = options.ttl || internals.defaults.rsvpTTL; 269 | 270 | // Construct envelope 271 | 272 | const envelope = { 273 | app: app.id, 274 | exp: Hawk.utils.now() + options.ttl, 275 | grant: grant.id 276 | }; 277 | 278 | // Stringify and encrypt 279 | 280 | return Iron.seal(envelope, encryptionPassword, options.iron || Iron.defaults); 281 | }; 282 | 283 | 284 | /* 285 | const ticket = { 286 | 287 | // Inputs into generate() 288 | 289 | exp: time in msec 290 | app: app id ticket is issued to 291 | scope: ticket scope 292 | grant: grant id 293 | user: user id 294 | dlg: app id of the delegating party 295 | 296 | // Added by generate() 297 | 298 | key: ticket secret key (Hawk) 299 | algorithm: ticket hmac algorithm (Hawk) 300 | id: ticket key id (Hawk) 301 | ext: application data { public, private } 302 | }; 303 | 304 | const options = { 305 | iron: {}, // Override Iron defaults 306 | keyBytes: 32, // Hawk key length 307 | hmacAlgorithm: 'sha256' // Hawk algorithm 308 | }; 309 | */ 310 | 311 | exports.generate = async function (ticket, encryptionPassword, options) { 312 | 313 | options = options || {}; 314 | 315 | // Generate ticket secret 316 | 317 | const random = Cryptiles.randomString(options.keyBytes || internals.defaults.keyBytes); 318 | ticket.key = random; 319 | ticket.algorithm = options.hmacAlgorithm || internals.defaults.hmacAlgorithm; 320 | 321 | // Ext data 322 | 323 | if (options.ext) { 324 | ticket.ext = {}; 325 | 326 | // Explicit copy to avoid unintentional leaking of private data as public or changes to options object 327 | 328 | if (options.ext.public !== undefined) { 329 | ticket.ext.public = options.ext.public; 330 | } 331 | 332 | if (options.ext.private !== undefined) { 333 | ticket.ext.private = options.ext.private; 334 | } 335 | } 336 | 337 | // Seal ticket 338 | 339 | const sealed = await Iron.seal(ticket, encryptionPassword, options.iron || Iron.defaults); 340 | ticket.id = sealed; 341 | 342 | // Hide private ext data 343 | 344 | if (ticket.ext) { 345 | if (ticket.ext.public !== undefined) { 346 | ticket.ext = ticket.ext.public; 347 | } 348 | else { 349 | delete ticket.ext; 350 | } 351 | } 352 | 353 | return ticket; 354 | }; 355 | 356 | 357 | // Parse ticket id 358 | 359 | /* 360 | const options = { 361 | iron: {} // Override Iron defaults 362 | }; 363 | */ 364 | 365 | exports.parse = async function (id, encryptionPassword, options) { 366 | 367 | options = options || {}; 368 | 369 | if (!encryptionPassword) { 370 | throw Boom.internal('Invalid encryption password'); 371 | } 372 | 373 | const ticket = await Iron.unseal(id, encryptionPassword, options.iron || Iron.defaults); 374 | ticket.id = id; 375 | return ticket; 376 | }; 377 | -------------------------------------------------------------------------------- /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 { describe, it } = exports.lab = Lab.script(); 21 | const expect = Code.expect; 22 | 23 | 24 | describe('Ticket', () => { 25 | 26 | const password = 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough'; 27 | 28 | describe('issue()', () => { 29 | 30 | it('should construct a valid ticket', async () => { 31 | 32 | const app = { 33 | id: '123', 34 | scope: ['a', 'b'] 35 | }; 36 | 37 | const grant = { 38 | id: 's81u29n1812', 39 | user: '456', 40 | exp: Oz.hawk.utils.now() + 5000, 41 | scope: ['a'] 42 | }; 43 | 44 | const options = { 45 | ttl: 10 * 60 * 1000, 46 | ext: { 47 | public: { 48 | x: 'welcome' 49 | }, 50 | private: { 51 | x: 123 52 | } 53 | } 54 | }; 55 | 56 | const envelope = await Oz.ticket.issue(app, grant, password, options); 57 | expect(envelope.ext).to.equal({ x: 'welcome' }); 58 | expect(envelope.exp).to.equal(grant.exp); 59 | expect(envelope.scope).to.equal(['a']); 60 | 61 | const ticket = await Oz.ticket.parse(envelope.id, password); 62 | expect(ticket.ext).to.equal(options.ext); 63 | 64 | const envelope2 = await Oz.ticket.reissue(ticket, grant, password); 65 | expect(envelope.ext).to.equal({ x: 'welcome' }); 66 | expect(envelope2.id).to.not.equal(envelope.id); 67 | }); 68 | 69 | it('errors on missing app', () => { 70 | 71 | expect(() => Oz.ticket.issue(null, null, password)).to.throw('Invalid application object'); 72 | }); 73 | 74 | it('errors on invalid app', () => { 75 | 76 | expect(() => Oz.ticket.issue({}, null, password)).to.throw('Invalid application object'); 77 | }); 78 | 79 | it('errors on invalid grant (missing id)', () => { 80 | 81 | expect(() => Oz.ticket.issue({ id: 'abc' }, {}, password)).to.throw('Invalid grant object'); 82 | }); 83 | 84 | it('errors on invalid grant (missing user)', () => { 85 | 86 | expect(() => Oz.ticket.issue({ id: 'abc' }, { id: '123' }, password)).to.throw('Invalid grant object'); 87 | }); 88 | 89 | it('errors on invalid grant (missing exp)', () => { 90 | 91 | expect(() => Oz.ticket.issue({ id: 'abc' }, { id: '123', user: 'steve' }, password)).to.throw('Invalid grant object'); 92 | }); 93 | 94 | it('errors on invalid grant (scope outside app)', () => { 95 | 96 | expect(() => Oz.ticket.issue({ id: 'abc', scope: ['a'] }, { id: '123', user: 'steve', exp: 1442690715989, scope: ['b'] }, password)).to.throw('Grant scope is not a subset of the application scope'); 97 | }); 98 | 99 | it('errors on invalid app scope', () => { 100 | 101 | expect(() => Oz.ticket.issue({ id: 'abc', scope: 'a' }, null, password)).to.throw('scope not instance of Array'); 102 | }); 103 | 104 | it('errors on invalid password', () => { 105 | 106 | expect(() => Oz.ticket.issue({ id: 'abc' }, null, '')).to.throw('Invalid encryption password'); 107 | }); 108 | }); 109 | 110 | describe('reissue()', () => { 111 | 112 | it('sets delegate to false', async () => { 113 | 114 | const app = { 115 | id: '123' 116 | }; 117 | 118 | const envelope = await Oz.ticket.issue(app, null, password); 119 | const ticket = await Oz.ticket.parse(envelope.id, password); 120 | 121 | const envelope2 = await Oz.ticket.reissue(ticket, null, password, { issueTo: '345', delegate: false }); 122 | expect(envelope2.delegate).to.be.false(); 123 | }); 124 | 125 | it('errors on issueTo when delegate is not allowed', async () => { 126 | 127 | const app = { 128 | id: '123' 129 | }; 130 | 131 | const options = { 132 | delegate: false 133 | }; 134 | 135 | const envelope = await Oz.ticket.issue(app, null, password, options); 136 | expect(envelope.delegate).to.be.false(); 137 | 138 | const ticket = await Oz.ticket.parse(envelope.id, password); 139 | expect(() => Oz.ticket.reissue(ticket, null, password, { issueTo: '345' })).to.throw('Ticket does not allow delegation'); 140 | }); 141 | 142 | it('errors on delegate override', async () => { 143 | 144 | const app = { 145 | id: '123' 146 | }; 147 | 148 | const options = { 149 | delegate: false 150 | }; 151 | 152 | const envelope = await Oz.ticket.issue(app, null, password, options); 153 | expect(envelope.delegate).to.be.false(); 154 | 155 | const ticket = await Oz.ticket.parse(envelope.id, password); 156 | expect(() => Oz.ticket.reissue(ticket, null, password, { delegate: true })).to.throw('Cannot override ticket delegate restriction'); 157 | }); 158 | 159 | it('errors on missing parent ticket', () => { 160 | 161 | expect(() => Oz.ticket.reissue(null, null, password)).to.throw('Invalid parent ticket object'); 162 | }); 163 | 164 | it('errors on missing password', () => { 165 | 166 | expect(() => Oz.ticket.reissue({}, null, '')).to.throw('Invalid encryption password'); 167 | }); 168 | 169 | it('errors on missing parent scope', () => { 170 | 171 | expect(() => Oz.ticket.reissue({}, null, password, { scope: ['a'] })).to.throw('New scope is not a subset of the parent ticket scope'); 172 | }); 173 | 174 | it('errors on invalid parent scope', () => { 175 | 176 | expect(() => Oz.ticket.reissue({ scope: 'a' }, null, password, { scope: ['a'] })).to.throw('scope not instance of Array'); 177 | }); 178 | 179 | it('errors on invalid options scope', () => { 180 | 181 | expect(() => Oz.ticket.reissue({ scope: ['a'] }, null, password, { scope: 'a' })).to.throw('scope not instance of Array'); 182 | }); 183 | 184 | it('errors on invalid grant (missing id)', () => { 185 | 186 | expect(() => Oz.ticket.reissue({}, {}, password)).to.throw('Invalid grant object'); 187 | }); 188 | 189 | it('errors on invalid grant (missing user)', () => { 190 | 191 | expect(() => Oz.ticket.reissue({}, { id: 'abc' }, password)).to.throw('Invalid grant object'); 192 | }); 193 | 194 | it('errors on invalid grant (missing exp)', () => { 195 | 196 | expect(() => Oz.ticket.reissue({}, { id: 'abc', user: 'steve' }, password)).to.throw('Invalid grant object'); 197 | }); 198 | 199 | it('errors on options.issueTo and ticket.dlg conflict', () => { 200 | 201 | expect(() => Oz.ticket.reissue({ dlg: '123' }, null, password, { issueTo: '345' })).to.throw('Cannot re-delegate'); 202 | }); 203 | 204 | it('errors on mismatching grants (missing grant)', () => { 205 | 206 | expect(() => Oz.ticket.reissue({ grant: '123' }, null, password)).to.throw('Parent ticket grant does not match options.grant'); 207 | }); 208 | 209 | it('errors on mismatching grants (missing parent)', () => { 210 | 211 | expect(() => Oz.ticket.reissue({}, { id: '123', user: 'steve', exp: 1442690715989 }, password)).to.throw('Parent ticket grant does not match options.grant'); 212 | }); 213 | 214 | it('errors on mismatching grants (different)', () => { 215 | 216 | expect(() => Oz.ticket.reissue({ grant: '234' }, { id: '123', user: 'steve', exp: 1442690715989 }, password)).to.throw('Parent ticket grant does not match options.grant'); 217 | }); 218 | }); 219 | 220 | describe('rsvp()', () => { 221 | 222 | it('errors on missing app', () => { 223 | 224 | expect(() => Oz.ticket.rsvp(null, { id: '123' }, password)).to.throw('Invalid application object'); 225 | }); 226 | 227 | it('errors on invalid app', () => { 228 | 229 | expect(() => Oz.ticket.rsvp({}, { id: '123' }, password)).to.throw('Invalid application object'); 230 | }); 231 | 232 | it('errors on missing grant', () => { 233 | 234 | expect(() => Oz.ticket.rsvp({ id: '123' }, null, password)).to.throw('Invalid grant object'); 235 | }); 236 | 237 | it('errors on invalid grant', () => { 238 | 239 | expect(() => Oz.ticket.rsvp({ id: '123' }, {}, password)).to.throw('Invalid grant object'); 240 | }); 241 | 242 | it('errors on missing password', () => { 243 | 244 | expect(() => Oz.ticket.rsvp({ id: '123' }, { id: '123' }, '')).to.throw('Invalid encryption password'); 245 | }); 246 | 247 | it('constructs a valid rsvp', async () => { 248 | 249 | const app = { 250 | id: '123' // App id 251 | }; 252 | 253 | const grant = { 254 | id: 's81u29n1812' // Grant 255 | }; 256 | 257 | const envelope = await Oz.ticket.rsvp(app, grant, password); 258 | const object = await Oz.ticket.parse(envelope, password); 259 | expect(object.app).to.equal(app.id); 260 | expect(object.grant).to.equal(grant.id); 261 | }); 262 | 263 | it('fails to construct a valid rsvp due to bad Iron options', async () => { 264 | 265 | const app = { 266 | id: '123' // App id 267 | }; 268 | 269 | const grant = { 270 | id: 's81u29n1812' // Grant 271 | }; 272 | 273 | const iron = Hoek.clone(Iron.defaults); 274 | iron.encryption = null; 275 | 276 | await expect(Oz.ticket.rsvp(app, grant, password, { iron })).to.reject('Bad options'); 277 | }); 278 | }); 279 | 280 | describe('generate()', () => { 281 | 282 | it('errors on random fail', async () => { 283 | 284 | const orig = Cryptiles.randomString; 285 | Cryptiles.randomString = function (size) { 286 | 287 | Cryptiles.randomString = orig; 288 | throw new Error('fake'); 289 | }; 290 | 291 | await expect(Oz.ticket.generate({}, password)).to.reject('fake'); 292 | }); 293 | 294 | it('errors on missing password', async () => { 295 | 296 | await expect(Oz.ticket.generate({}, null)).to.reject('Empty password'); 297 | }); 298 | 299 | it('generates a ticket with only public ext', async () => { 300 | 301 | const input = {}; 302 | const ticket = await Oz.ticket.generate(input, password, { ext: { public: { x: 1 } } }); 303 | expect(ticket.ext.x).to.equal(1); 304 | }); 305 | 306 | it('generates a ticket with only private ext', async () => { 307 | 308 | const input = {}; 309 | const ticket = await Oz.ticket.generate(input, password, { ext: { private: { x: 1 } } }); 310 | expect(ticket.ext).to.not.exist(); 311 | }); 312 | 313 | it('overrides hawk options', async () => { 314 | 315 | const input = {}; 316 | const ticket = await Oz.ticket.generate(input, password, { keyBytes: 10, hmacAlgorithm: 'something' }); 317 | expect(ticket.key).to.have.length(10); 318 | expect(ticket.algorithm).to.equal('something'); 319 | }); 320 | }); 321 | 322 | describe('parse()', () => { 323 | 324 | it('errors on wrong password', async () => { 325 | 326 | const app = { 327 | id: '123' 328 | }; 329 | 330 | const grant = { 331 | id: 's81u29n1812', 332 | user: '456', 333 | exp: Oz.hawk.utils.now() + 5000, 334 | scope: ['a', 'b'] 335 | }; 336 | 337 | const options = { 338 | ttl: 10 * 60 * 1000 339 | }; 340 | 341 | const envelope = await Oz.ticket.issue(app, grant, password, options); 342 | await expect(Oz.ticket.parse(envelope.id, 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough_x')).to.reject('Bad hmac value'); 343 | }); 344 | 345 | it('errors on missing password', async () => { 346 | 347 | await expect(Oz.ticket.parse('abc', '')).to.reject('Invalid encryption password'); 348 | }); 349 | }); 350 | }); 351 | 352 | 353 | -------------------------------------------------------------------------------- /test/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Http = require('http'); 6 | 7 | const Code = require('code'); 8 | const Hoek = require('hoek'); 9 | const Iron = require('iron'); 10 | const Lab = require('lab'); 11 | const Oz = require('..'); 12 | const Wreck = require('wreck'); 13 | 14 | 15 | // Declare internals 16 | 17 | const internals = {}; 18 | 19 | 20 | // Test shortcuts 21 | 22 | const { describe, it } = exports.lab = Lab.script(); 23 | const expect = Code.expect; 24 | 25 | 26 | describe('Client', () => { 27 | 28 | describe('header()', () => { 29 | 30 | it('generates header', () => { 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, {}); 40 | expect(header).to.exist(); 41 | }); 42 | }); 43 | 44 | describe('Connection', () => { 45 | 46 | it('obtains an application ticket and requests resource', async () => { 47 | 48 | const mock = new internals.Mock(); 49 | const uri = await mock.start(); 50 | 51 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 52 | const { result: result1, code: code1, ticket: ticket1 } = await connection.app('/'); 53 | expect(result1).to.equal('GET /'); 54 | expect(code1).to.equal(200); 55 | expect(ticket1).to.equal(connection._appTicket); 56 | 57 | const { result: result2, code: code2, ticket: ticket2 } = await connection.request('/resource', ticket1); 58 | expect(result2).to.equal('GET /resource'); 59 | expect(code2).to.equal(200); 60 | expect(ticket2).to.equal(ticket1); 61 | 62 | const ticket3 = await connection.reissue(ticket2); 63 | expect(ticket3).to.not.equal(ticket2); 64 | 65 | const { result: result4, code: code4, ticket: ticket4 } = await connection.request('/resource', ticket3); 66 | expect(result4).to.equal('GET /resource'); 67 | expect(code4).to.equal(200); 68 | expect(ticket4).to.equal(ticket3); 69 | 70 | await mock.stop(); 71 | }); 72 | 73 | it('errors on payload read fail', async () => { 74 | 75 | const mock = new internals.Mock(); 76 | const uri = await mock.start(); 77 | 78 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 79 | 80 | let count = 0; 81 | const orig = Wreck.read; 82 | Wreck.read = function (...args) { 83 | 84 | if (++count === 1) { 85 | return orig.apply(Wreck, args); 86 | } 87 | 88 | Wreck.read = orig; 89 | return Promise.reject(new Error('fail read')); 90 | }; 91 | 92 | await expect(connection._requestAppTicket()).to.reject(); 93 | await mock.stop(); 94 | }); 95 | 96 | it('errors on invalid app response', async () => { 97 | 98 | const mock = new internals.Mock({ failApp: true }); 99 | const uri = await mock.start(); 100 | 101 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 102 | await expect(connection.app('/')).to.not.reject(); 103 | await mock.stop(); 104 | }); 105 | 106 | describe('request()', () => { 107 | 108 | it('automatically refreshes ticket', async () => { 109 | 110 | const mock = new internals.Mock({ ttl: 20 }); 111 | const uri = await mock.start(); 112 | 113 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 114 | const { result: result1, code: code1, ticket: ticket1 } = await connection.app('/'); 115 | expect(result1).to.equal('GET /'); 116 | expect(code1).to.equal(200); 117 | expect(ticket1).to.equal(connection._appTicket); 118 | 119 | await Hoek.wait(30); 120 | 121 | const { result: result2, code: code2, ticket: ticket2 } = await connection.request('/resource', ticket1, { method: 'POST' }); 122 | expect(result2).to.equal('POST /resource'); 123 | expect(code2).to.equal(200); 124 | expect(ticket2).to.not.equal(ticket1); 125 | 126 | await mock.stop(); 127 | }); 128 | 129 | it('errors on socket fail', async () => { 130 | 131 | const mock = new internals.Mock(); 132 | const uri = await mock.start(); 133 | 134 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 135 | const { result: result1, code: code1, ticket: ticket1 } = await connection.app('/'); 136 | expect(result1).to.equal('GET /'); 137 | expect(code1).to.equal(200); 138 | expect(ticket1).to.equal(connection._appTicket); 139 | 140 | const orig = Wreck.request; 141 | Wreck.request = function () { 142 | 143 | Wreck.request = orig; 144 | return Promise.reject(new Error('bad socket')); 145 | }; 146 | 147 | await expect(connection.request('/resource', ticket1)).to.reject(); 148 | await mock.stop(); 149 | }); 150 | 151 | it('errors on reissue fail', async () => { 152 | 153 | const mock = new internals.Mock({ ttl: 10 }); 154 | const uri = await mock.start(); 155 | 156 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 157 | const { result: result1, code: code1, ticket: ticket1 } = await connection.app('/'); 158 | expect(result1).to.equal('GET /'); 159 | expect(code1).to.equal(200); 160 | expect(ticket1).to.equal(connection._appTicket); 161 | 162 | await Hoek.wait(11); // Expire ticket 163 | 164 | let count = 0; 165 | const orig = Wreck.request; 166 | Wreck.request = function (...args) { 167 | 168 | if (++count === 1) { 169 | return orig.apply(Wreck, args); 170 | } 171 | 172 | Wreck.request = orig; 173 | return Promise.reject(new Error('bad socket')); 174 | }; 175 | 176 | await expect(connection.request('/resource', ticket1, { method: 'POST' })).to.reject(); 177 | await mock.stop(); 178 | }); 179 | 180 | it('does not reissue a 401 without payload', async () => { 181 | 182 | const mock = new internals.Mock({ empty401: true }); 183 | const uri = await mock.start(); 184 | 185 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 186 | const { result, code } = await connection.app('/'); 187 | expect(code).to.equal(401); 188 | expect(result).to.equal(''); 189 | 190 | await mock.stop(); 191 | }); 192 | }); 193 | 194 | describe('app()', () => { 195 | 196 | it('reuses application ticket', async () => { 197 | 198 | const mock = new internals.Mock(); 199 | const uri = await mock.start(); 200 | 201 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 202 | const { result: result1, code: code1, ticket: ticket1 } = await connection.app('/'); 203 | expect(result1).to.equal('GET /'); 204 | expect(code1).to.equal(200); 205 | expect(ticket1).to.equal(connection._appTicket); 206 | 207 | const { result: result2, code: code2, ticket: ticket2 } = await connection.app('/resource'); 208 | expect(result2).to.equal('GET /resource'); 209 | expect(code2).to.equal(200); 210 | expect(ticket2).to.equal(ticket1); 211 | 212 | await mock.stop(); 213 | }); 214 | 215 | it('handles app ticket request errors', async () => { 216 | 217 | const mock = new internals.Mock(); 218 | const uri = await mock.start(); 219 | 220 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 221 | connection._requestAppTicket = (callback) => callback(new Error('failed')); 222 | await expect(connection.app('/')).to.reject(); 223 | await mock.stop(); 224 | }); 225 | }); 226 | 227 | describe('reissue()', () => { 228 | 229 | it('errors on non 200 reissue response', async () => { 230 | 231 | const mock = new internals.Mock({ failRefresh: true }); 232 | const uri = await mock.start(); 233 | 234 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 235 | const { result, code, ticket } = await connection.app('/'); 236 | expect(result).to.equal('GET /'); 237 | expect(code).to.equal(200); 238 | expect(ticket).to.equal(connection._appTicket); 239 | 240 | await expect(connection.reissue(ticket)).to.reject(); 241 | await mock.stop(); 242 | }); 243 | }); 244 | 245 | describe('_request()', () => { 246 | 247 | it('errors on payload read fail', async () => { 248 | 249 | const mock = new internals.Mock(); 250 | const uri = await mock.start(); 251 | 252 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 253 | const { ticket: ticket1 } = await connection.app('/'); 254 | 255 | let count = 0; 256 | const orig = Wreck.read; 257 | Wreck.read = function (...args) { 258 | 259 | if (++count === 1) { 260 | return orig.apply(Wreck, args); 261 | } 262 | 263 | Wreck.read = orig; 264 | return Promise.reject(new Error('fail read')); 265 | }; 266 | 267 | await expect(connection._request('GET', '/', null, ticket1)).to.reject(); 268 | await mock.stop(); 269 | }); 270 | }); 271 | 272 | describe('_requestAppTicket()', () => { 273 | 274 | it('errors on socket fail', async () => { 275 | 276 | const mock = new internals.Mock(); 277 | const uri = await mock.start(); 278 | 279 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 280 | 281 | const orig = Wreck.request; 282 | Wreck.request = function () { 283 | 284 | Wreck.request = orig; 285 | return Promise.reject(new Error('bad socket')); 286 | }; 287 | 288 | await expect(connection._requestAppTicket()).to.reject(); 289 | await mock.stop(); 290 | }); 291 | 292 | it('errors on redirection', async () => { 293 | 294 | const mock = new internals.Mock(); 295 | const uri = await mock.start(); 296 | 297 | const connection = new Oz.client.Connection({ uri, credentials: internals.app }); 298 | 299 | const orig = Wreck.request; 300 | Wreck.request = async (...args) => { 301 | 302 | Wreck.request = orig; 303 | const response = await Wreck.request(...args); 304 | response.statusCode = 300; 305 | return response; 306 | }; 307 | 308 | await expect(connection._requestAppTicket()).to.reject(); 309 | await mock.stop(); 310 | }); 311 | }); 312 | }); 313 | }); 314 | 315 | 316 | internals.app = { 317 | id: 'social', 318 | scope: ['a', 'b', 'c'], 319 | key: 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn', 320 | algorithm: 'sha256' 321 | }; 322 | 323 | 324 | internals.Mock = class { 325 | 326 | constructor(options = {}) { 327 | 328 | const settings = { 329 | encryptionPassword: 'passwordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpassword', 330 | loadAppFunc: function (id) { 331 | 332 | return internals.app; 333 | }, 334 | ticket: { 335 | ttl: options.ttl || 10 * 60 * 1000, 336 | iron: Iron.defaults 337 | }, 338 | hawk: {} 339 | }; 340 | 341 | this.listener = Http.createServer(async (req, res) => { 342 | 343 | const reply = (err, payload, code) => { 344 | 345 | code = code || (err ? err.output.statusCode : 200); 346 | const headers = (err ? err.output.headers : {}); 347 | headers['Content-Type'] = 'application/json'; 348 | const body = JSON.stringify(err ? err.output.payload : payload); 349 | 350 | res.writeHead(code, headers); 351 | res.end(body); 352 | }; 353 | 354 | const result = await Wreck.read(req); 355 | 356 | if (req.url === '/oz/app') { 357 | try { 358 | const payload = await Oz.endpoints.app(req, result, settings); 359 | return reply(null, payload, 200); 360 | } 361 | catch (err) { 362 | return reply(err, null, options.failApp ? 400 : 200); 363 | } 364 | } 365 | 366 | if (req.url === '/oz/reissue') { 367 | try { 368 | const payload = await Oz.endpoints.reissue(req, result, settings); 369 | return reply(null, payload, options.failRefresh ? 400 : 200); 370 | } 371 | catch (err) { 372 | return reply(err, null, 400); 373 | } 374 | } 375 | 376 | if (options.empty401) { 377 | return reply(null, '', 401); 378 | } 379 | 380 | try { 381 | await Oz.server.authenticate(req, settings.encryptionPassword, settings); 382 | return reply(null, req.method + ' ' + req.url); 383 | } 384 | catch (err) { 385 | return reply(err); 386 | } 387 | }); 388 | }; 389 | 390 | start() { 391 | 392 | return new Promise((resolve) => { 393 | 394 | this.listener.listen(0, 'localhost', () => { 395 | 396 | const address = this.listener.address(); 397 | return resolve('http://localhost:' + address.port); 398 | }); 399 | }); 400 | } 401 | 402 | stop() { 403 | 404 | return new Promise((resolve) => this.listener.close(resolve)); 405 | } 406 | }; 407 | -------------------------------------------------------------------------------- /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: **5.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://api.travis-ci.org/hueniverse/oz.svg?branch=master)](http://travis-ci.org/hueniverse/oz) 13 | 14 | # Table of Content 15 | 16 | - [Protocol](#protocol) 17 | - [Workflow](#workflow) 18 | - [Application](#application) 19 | - [User](#user) 20 | - [Ticket](#ticket) 21 | - [Grant](#grant) 22 | - [Scope](#scope) 23 | - [Rsvp](#rsvp) 24 | - [API](#api) 25 | - [Shared objects](#shared-objects) 26 | - [`app` object](#app-object) 27 | - [`grant` object](#grant-object) 28 | - [`ticket` response](#ticket-response) 29 | - [`Oz.client`](#ozclient) 30 | - [`Oz.client.header(uri, method, ticket, [options])`](#ozclientheaderuri-method-ticket-options) 31 | - [`new Oz.client.Connection(options)`](#new-ozclientconnectionoptions) 32 | - [`await connection.request(path, ticket, options)`](#await-connectionrequestpath-ticket-options) 33 | - [`await connection.app(path, options)`](#await-connectionapppath-options) 34 | - [`await connection.reissue(ticket)`](#await-connectionreissueticket) 35 | - [`Oz.endpoints`](#ozendpoints) 36 | - [Endpoints options](#endpoints-options) 37 | - [`encryptionPassword`](#encryptionpassword) 38 | - [`loadAppFunc`](#loadappfunc) 39 | - [`loadGrantFunc`](#loadgrantfunc) 40 | - [`await endpoints.app(req, payload, options)`](#await-endpointsappreq-payload-options) 41 | - [`await endpoints.reissue(req, payload, options)`](#await-endpointsreissuereq-payload-options) 42 | - [`await endpoints.rsvp(req, payload, options)`](#await-endpointsrsvpreq-payload-options) 43 | - [`Oz.hawk`](#ozhawk) 44 | - [`Oz.scope`](#ozscope) 45 | - [`Oz.scope.validate(scope)`](#ozscopevalidatescope) 46 | - [`Oz.scope.isSubset(scope, subset)`](#ozscopeissubsetscope-subset) 47 | - [`Oz.server`](#ozserver) 48 | - [`await Oz.server.authenticate(req, encryptionPassword, options)`](#await-ozserverauthenticatereq-encryptionpassword-options) 49 | - [`Oz.ticket`](#ozticket) 50 | - [Ticket options](#ticket-options) 51 | - [`await ticket.issue(app, grant, encryptionPassword, options)`](#await-ticketissueapp-grant-encryptionpassword-options) 52 | - [`await ticket.reissue(parentTicket, grant, encryptionPassword, options)`](#await-ticketreissueparentticket-grant-encryptionpassword-options) 53 | - [`await ticket.rsvp(app, grant, encryptionPassword, options)`](#await-ticketrsvpapp-grant-encryptionpassword-options) 54 | - [`await ticket.generate(ticket, encryptionPassword, options)`](#await-ticketgenerateticket-encryptionpassword-options) 55 | - [`await ticket.parse(id, encryptionPassword, options)`](#await-ticketparseid-encryptionpassword-options) 56 | - [Security Considerations](#security-considerations) 57 | - [Ticket and Application Hawk Credentials Transmission](#ticket-and-application-hawk-credentials-transmission) 58 | - [Plaintext Storage of Ticket and Application Hawk Credentials](#plaintext-storage-of-ticket-and-application-hawk-credentials) 59 | - [Entropy of Keys](#entropy-of-keys) 60 | - [Application Redirect URI](#application-redirect-uri) 61 | 62 | ## Protocol 63 | 64 | Oz builds on the well-understood concepts behind the [OAuth](https://tools.ietf.org/html/rfc5849) 65 | protocol. While the terminology has been updated to reflect the common terms used today when 66 | building applications with third-party access, the overall architecture is the same. This document 67 | assumes the reader is familiar with the OAuth 1.0a protocol workflow. 68 | 69 | ### Workflow 70 | 71 | 1. The [application](#application) uses its previously issued [Hawk](https://github.com/hueniverse/hawk) 72 | credentials to authenticate with the server and request an application [ticket](#ticket). If valid, 73 | the server issues an application ticket. 74 | 2. The application directs the [user](#user) to grant it authorization by providing the user with its 75 | application identifier. The user authenticates with the server, reviews the authorization 76 | [grant](#grant) and its [scope](#scope), and if approved the server returns an [rsvp](#rsvp). 77 | 3. The user returns to the application with the rsvp which the application uses to request a new 78 | user-specific ticket. If valid, the server returns a new ticket. 79 | 4. The application uses the user-ticket to access the user's protected resources. 80 | 81 | ### Application 82 | 83 | Oz is an application-to-server authorization protocol. This means credentials are issued only to 84 | applications, not to users. The method through which users authenticate is outside the scope of 85 | this protocol. 86 | 87 | The application represents a third-party accessing protected resource on the server. This 88 | third-party can be another server, a native app, a single-page-app, or any other application using 89 | web resources. The protected resources can be under the control of the application itself or under 90 | the control of a user who grants the application access. 91 | 92 | Each application definition includes: 93 | - `id` - a unique application identifier. 94 | - `scope` - the default application [scope](#scope). 95 | - `delegate` - if `true`, the application is allowed to delegate a ticket to another application. 96 | Defaults to `false`. 97 | 98 | Applications must be registered with the server prior to using Oz. The method through which 99 | applications register is outside the scope of this protocol. When an application registers, it is 100 | issued a set of [Hawk](https://github.com/hueniverse/hawk) credentials. The application uses these 101 | credentials to obtain an Oz [ticket](#ticket). 102 | 103 | The application Hawk credentials include: 104 | - `id` - the unique application identifier. 105 | - `key` - a shared secret used to authenticate. 106 | - `algorithm` - the HMAC algorithm used to authenticate (e.g. HMAC-SHA256). 107 | 108 | The [Hawk](https://github.com/hueniverse/hawk) protocol supports two Oz-specific header attributes 109 | which are used for authenticating Oz applications (`app` and `dlg`). 110 | 111 | ### User 112 | 113 | Applications act on behalf of users. Users are usually people with protected resources on the 114 | server who would like to use the application to access those protected resources. For the purpose 115 | of the Oz protocol, each user must have a unique identifier which is used by the protocol to record 116 | access rights. The method through which users are registered, authenticated, and managed is beyond 117 | the scope of this protocol. 118 | 119 | ### Ticket 120 | 121 | An Oz ticket is a set of [Hawk](https://github.com/hueniverse/hawk) credentials used by the 122 | application to access protected resources. Just like any other Hawk credentials, the ticket 123 | includes: 124 | - `id` - a unique identifier for the authorized access. 125 | - `key` - a shared secret used to authenticate. 126 | - `algorithm` - the HMAC algorithm used to authenticate (e.g. HMAC-SHA256). 127 | 128 | However, unlike most Hawk credential identifiers, the Oz identifier is an encoded 129 | [Iron](https://github.com/hueniverse/iron) string which when decoded contains: 130 | - `exp` - ticket expiration time in milliseconds since 1/1/1970. 131 | - `app` - the application id the ticket was issued to. 132 | - `user` - the user id if the ticket represents access to user resources. If no user id is included, 133 | the ticket allows the application access to the application own resources only. 134 | - `scope` - the ticket [scope](#scope). Defaults to `[]` if no scope is specified. 135 | - `delegate` - if `false`, the ticket cannot be delegated regardless of the application permissions. 136 | Defaults to `true` which means use the application permissions to delegate. 137 | - `grant` - if `user` is set, includes the [grant](#grant) identifier referencing the authorization 138 | granted by the user to the application. Can be a unique identifier or string encoding the grant 139 | information as long as the server is able to parse the information later. 140 | - `dlg` - if the ticket is the result of access delegation, the application id of the delegating 141 | application. 142 | - `ext` - custom server data where: 143 | - `public` - also made available to the application when the ticket is sent back. 144 | - `private` - available only within the encoded ticket. 145 | 146 | When a ticket is generated and sent to the application by the server, the response includes all of 147 | the above properties with the exception of `ext` which is included but only with the content of 148 | `ext.public` if present. 149 | 150 | The ticket expiration can be shorter than the grant expiration in which case, the application can 151 | reissue the ticket. This provides the ability to limit the time credentials are valid but allowing 152 | grants to have longer lifetime. 153 | 154 | When tickets are reissued, they can be constrained to less scope or duration, and can also be 155 | issued to another application for access delegation. 156 | 157 | #### Grant 158 | 159 | A grant is the authorization given to an application by a user to access the user's protected 160 | resources. Grants can be persisted in a database (usually to support revocation) or can be self 161 | describing (using an encoded identifier). Each grant contains: 162 | - `id` - the grant identifier, allowing the server to retrieve or recreate the grant information. 163 | - `exp` - authorization expiration time in milliseconds since 1/1/1970. 164 | - `user` - the user id who the user who authorized access. 165 | - `scope` - the authorized [scope](#scope). Defaults to the application scope if no scope is 166 | specified. 167 | 168 | #### Scope 169 | 170 | Scope is an array of strings, each represents an implementation-specific permission on the server. 171 | Each scope string adds additional permissions to the application (i.e. `['a', 'b']` grants the 172 | application access to both the `'a'` and `'b'` rights, individually). 173 | 174 | Each application has a default scope which is included in the tickets issued to the application 175 | unless the grant specifies a subset of the application scope. Applications cannot be granted scopes 176 | not present in their default set. 177 | 178 | #### Rsvp 179 | 180 | When the user authorizes the application access request, the server issues an rsvp which is an 181 | encoded string containing the application identifier, the grant identifier, and an expiration. 182 | 183 | ## API 184 | 185 | The Oz public API is offered as a full toolkit to implement the protocol as-is or to modify it to 186 | fit custom security needs. Most implementations will only need to use the [endpoints functions](#ozendpoints) 187 | methods and the [`ticket.rsvp()`](#await-ticketrsvpapp-grant-encryptionPassword-options) method 188 | directly. 189 | 190 | ### Shared objects 191 | 192 | #### `app` object 193 | 194 | An object describing an application where: 195 | - `id` - the application identifier. 196 | - `scope` - an array with the default application scope. 197 | - `delegate` - if `true`, the application is allowed to delegate a ticket to another application. 198 | Defaults to `false`. 199 | - `key` - the shared secret used to authenticate. 200 | - `algorithm` - the HMAC algorithm used to authenticate (e.g. HMAC-SHA256). 201 | 202 | #### `grant` object 203 | 204 | An object describing a user grant where: 205 | - `id` - the grant identifier. 206 | - `app` - the application identifier. 207 | - `user` - the user identifier. 208 | - `exp` - grant expiration time in milliseconds since 1/1/1970. 209 | - `scope` - an array with the scope granted by the user to the application. 210 | 211 | #### `ticket` response 212 | 213 | An object describing a ticket and its public properties: 214 | - `id` - the ticket identifier used for making authenticated Hawk requests. 215 | - `key` - a shared secret used to authenticate. 216 | - `algorithm` - the HMAC algorithm used to authenticate (e.g. HMAC-SHA256). 217 | - `exp` - ticket expiration time in milliseconds since 1/1/1970. 218 | - `app` - the application id the ticket was issued to. 219 | - `user` - the user id if the ticket represents access to user resources. If no user id is 220 | included, the ticket allows the application access to the application own resources only. 221 | - `scope` - the ticket [scope](#scope). Defaults to `[]` if no scope is specified. 222 | - `grant` - if `user` is set, includes the [grant](#grant) identifier referencing the authorization 223 | granted by the user to the application. Can be a unique identifier or string encoding the grant 224 | information as long as the server is able to parse the information later. 225 | - `delegate` - if `false`, the ticket cannot be delegated regardless of the application permissions. 226 | Defaults to `true` which means use the application permissions to delegate. 227 | - `dlg` - if the ticket is the result of access delegation, the application id of the delegating 228 | application. 229 | - `ext` - custom server public data attached to the ticket. 230 | 231 | ### `Oz.client` 232 | 233 | Utilities used for making authenticated Oz requests. 234 | 235 | #### `Oz.client.header(uri, method, ticket, [options])` 236 | 237 | A convenience utility to generate the application Hawk request authorization header for making 238 | authenticated Oz requests where: 239 | - `uri` - the request URI. 240 | - `method` - the request HTTP method. 241 | - `ticket` - the authorization [ticket](#ticket-response). 242 | - `options` - additional Hawk `Hawk.client.header()` options. 243 | 244 | #### `new Oz.client.Connection(options)` 245 | 246 | Creates an **oz** client connection manager for easier access to protected resources. The client 247 | manages the ticket lifecycle and will automatically refresh the ticken when expired. Accepts the 248 | following options: 249 | - `endpoints` - an object containing the server protocol endpoints: 250 | `app` - the application credentials endpoint path. Defaults to `'/oz/app'`. 251 | `reissue` - the ticket reissue endpoint path. Defaults to `'/oz/reissue'`. 252 | - `uri` - required, the server full root uri without path (e.g. 'https://example.com'). 253 | - `credentials` - required, the application **hawk** credentials. 254 | 255 | ##### `await connection.request(path, ticket, options)` 256 | 257 | Requests a protected resource where: 258 | - `path` - the resource path (e.g. '/resource'). 259 | - `ticket` - the application or user ticket. If the ticket is expired, it will automatically 260 | attempt to refresh it. 261 | - `options` - optional configuration object where: 262 | - `method` - the HTTP method (e.g. 'GET'). Defaults to `'GET'`. 263 | - `payload` - the request payload object or string. Defaults to no payload. 264 | 265 | Return value: `{ result, code, ticket }` where: 266 | - `result` - the requested resource (parsed to object if JSON). 267 | - `code` - the HTTP response code. 268 | - `ticket` - the ticket used to make the request (may be different from the ticket provided 269 | when the ticket was expired and refreshed). 270 | - throws request errors. 271 | 272 | ##### `await connection.app(path, options)` 273 | 274 | Requests a protected resource using a shared application ticket where: 275 | - `path` - the resource path (e.g. '/resource'). 276 | - `options` - optional configuration object where: 277 | - `method` - the HTTP method (e.g. 'GET'). Defaults to `'GET'`. 278 | - `payload` - the request payload object or string. Defaults to no payload. 279 | 280 | Return value: `{ result, code, ticket }` where: 281 | - `result` - the requested resource (parsed to object if JSON). 282 | - `code` - the HTTP response code. 283 | - `ticket` - the ticket used to make the request (may be different from the ticket provided 284 | when the ticket was expired and refreshed). 285 | - throws request errors. 286 | 287 | Once an application ticket is obtained internally using the provided **hawk** credentials in the 288 | constructor, it will be reused by called to `connection.app()`. If it expires, it will 289 | automatically refresh and stored for future usage. 290 | 291 | ##### `await connection.reissue(ticket)` 292 | 293 | Reissues (refresh) a ticket where: 294 | - `ticket` - the ticket being reissued. 295 | 296 | Return value: the reissued ticket. 297 | 298 | ### `Oz.endpoints` 299 | 300 | The endpoint methods provide a complete HTTP request handler implementation which is designed to 301 | be plugged into an HTTP framework such as [**hapi**](http://hapijs.com). The 302 | [**scarecrow**](https://github.com/hueniverse/scarecrow) plugin provides an example of how these 303 | methods integrate with an existing server implementation. 304 | 305 | #### Endpoints options 306 | 307 | Each endpoint method accepts a set of options. 308 | 309 | ##### `encryptionPassword` 310 | 311 | A required string used to generate the ticket encryption key. Must be kept confidential. The string 312 | must be the same across all Oz methods and deployments in order to allow the server to parse and 313 | generate compatible encoded strings. 314 | 315 | The `encryptionPassword` value is passed directly to the [Iron](https://github.com/hueniverse/iron) 316 | module which supports additional inputs for pre-generated encryption and integrity keys as well as 317 | password rotation. 318 | 319 | ##### `loadAppFunc` 320 | 321 | The application lookup method using the signature `async function(id)` where: 322 | - `id` - the application identifier being requested. 323 | - the function must return an [application](#app-object) object or throw an error; 324 | 325 | ##### `loadGrantFunc` 326 | 327 | The grant lookup method using the signature `async function(id)` where: 328 | - `id` - the grant identifier being requested. 329 | - the function must return an object `{ grant, ext }` or throw an error where: 330 | - `grant` - a [grant](#grant-object) object. 331 | - `ext` - an optional object used to include custom server data in the ticket and response where: 332 | - `public` - an object which is included in the response under `ticket.ext` and in 333 | the encoded ticket as `ticket.ext.public`. 334 | - `private` - an object which is included only in the encoded ticket as 335 | `ticket.ext.private`. 336 | 337 | #### `await endpoints.app(req, payload, options)` 338 | 339 | Authenticates an application request and if valid, issues an application ticket where: 340 | - `req` - the node HTTP server request object. 341 | - `payload` - this argument is ignored and is defined only to keep the endpoint method signature 342 | consistent with the other endpoints. 343 | - `options` - protocol [configuration](#endpoints-options) options where: 344 | - `encryptionPassword` - required. 345 | - `loadAppFunc` - required. 346 | - `ticket` - optional [ticket options](#ticket-options) used for parsing and issuance. 347 | - `hawk` - optional [Hawk](https://github.com/hueniverse/hawk) configuration object. Defaults to 348 | the Hawk defaults. 349 | 350 | Return value: a [ticket response](#ticket-response) object or throws an error. 351 | 352 | #### `await endpoints.reissue(req, payload, options)` 353 | 354 | Reissue an existing ticket (the ticket used to authenticate the request) where: 355 | - `req` - the node HTTP server request object. 356 | - `payload` - The HTTP request payload fully parsed into an object with the following optional keys: 357 | - `issueTo` - a different application identifier than the one of the current application. Used 358 | to delegate access between applications. Defaults to the current application. 359 | - `scope` - an array of scope strings which must be a subset of the ticket's granted scope. 360 | Defaults to the original ticket scope. 361 | - `options` - protocol [configuration](#endpoints-options) options where: 362 | - `encryptionPassword` - required. 363 | - `loadAppFunc` - required. 364 | - `loadGrantFunc` - required. 365 | - `ticket` - optional [ticket options](#ticket-options) used for parsing and issuance. 366 | - `hawk` - optional [Hawk](https://github.com/hueniverse/hawk) configuration object. Defaults to 367 | the Hawk defaults. 368 | 369 | Return value: a [ticket response](#ticket-response) object or throws an error. 370 | 371 | #### `await endpoints.rsvp(req, payload, options)` 372 | 373 | Authenticates an application request and if valid, exchanges the provided rsvp with a ticket where: 374 | - `req` - the node HTTP server request object. 375 | - `payload` - The HTTP request payload fully parsed into an object with the following keys: 376 | - `rsvp` - the required rsvp string provided to the user to bring back to the application after 377 | granting authorization. 378 | - `options` - protocol [configuration](#endpoints-options) options where: 379 | - `encryptionPassword` - required. 380 | - `loadAppFunc` - required. 381 | - `loadGrantFunc` - required. 382 | - `ticket` - optional [ticket options](#ticket-options) used for parsing and issuance. 383 | - `hawk` - optional [Hawk](https://github.com/hueniverse/hawk) configuration object. Defaults to 384 | the Hawk defaults. 385 | 386 | Return value: a [ticket response](#ticket-response) object or throws an error. 387 | 388 | ### `Oz.hawk` 389 | 390 | Provides direct access to the underlying [Hawk](https://github.com/hueniverse/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 | #### `await Oz.server.authenticate(req, encryptionPassword, options)` 416 | 417 | Authenticates an incoming request using [Hawk](https://github.com/hueniverse/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/hueniverse/hawk) configuration object. Defaults to 425 | the Hawk defaults. 426 | 427 | Return value: `{ ticket, artifacts }` or throws an error where: 428 | - `ticket` - the decoded [ticket response](#ticket-response) object. 429 | - `artifacts` - Hawk protocol artifacts. 430 | 431 | ### `Oz.ticket` 432 | 433 | Ticket issuance, parsing, encoding, and re-issuance utilities. 434 | 435 | #### Ticket options 436 | 437 | The following are the supported ticket parsing and issuance options passed to the corresponding 438 | ticket methods. Each endpoint utilizes a different subset of these options but it is safe to pass 439 | one common object to all (it will ignore unused options): 440 | - `ttl` - when generating a ticket, sets the ticket lifetime in milliseconds. Defaults to 441 | `3600000` (1 hour) for tickets and `60000` (1 minutes) for rsvps. 442 | - `delegate` - if `false`, the ticket cannot be delegated regardless of the application permissions. 443 | Defaults to `true` which means use the application permissions to delegate. 444 | - `iron` - overrides the default [Iron](https://github.com/hueniverse/iron) configuration. 445 | - `keyBytes` - the [Hawk](https://github.com/hueniverse/hawk) key length in bytes. Defaults to 446 | `32`. 447 | - `hmacAlgorithm` - the [Hawk](https://github.com/hueniverse/hawk) HMAC algorithm. Defaults to 448 | `sha256`. 449 | - `ext` - an object used to provide custom server data to be included in the ticket (this option 450 | will be ignored when passed to an endpoint method and the `loadGrantFunc` function returns an 451 | `ext` value) where: 452 | - `public` - an object which is included in the response under `ticket.ext` and in 453 | the encoded ticket as `ticket.ext.public`. 454 | - `private` - an object which is included only in the encoded ticket as 455 | `ticket.ext.private`. 456 | 457 | #### `await ticket.issue(app, grant, encryptionPassword, options)` 458 | 459 | Issues a new application or user ticket where: 460 | - `app` - the application [object](#app-object) the ticket is being issued to. 461 | - `grant` - the grant [object](#grant-object) the ticket is being issued with if the ticket 462 | represents user access. `null` if the ticket is an application-only ticket. 463 | - `encryptionPassword` - the ticket [encryption password](#encryptionPassword). 464 | - `options` - ticket generation [options](#ticket-options). 465 | 466 | Return value: a [ticket response](#ticket-response) object. 467 | 468 | #### `await ticket.reissue(parentTicket, grant, encryptionPassword, options)` 469 | 470 | Reissues a application or user ticket where: 471 | - `parentTicket` - the [ticket](#ticket-response) object being reissued. 472 | - `grant` - the grant [object](#grant-object) the ticket is being issued with if the ticket 473 | represents user access. `null` if the ticket is an application-only ticket. 474 | - `encryptionPassword` - the ticket [encryption password](#encryptionPassword). 475 | - `options` - ticket generation [options](#ticket-options). 476 | 477 | Return value: a [ticket response](#ticket-response) object or throws an error. 478 | 479 | #### `await ticket.rsvp(app, grant, encryptionPassword, options)` 480 | 481 | Generates an rsvp string representing a user grant where: 482 | - `app` - the application [object](#app-object) the ticket is being issued to. 483 | - `grant` - the grant [object](#grant-object) the ticket is being issued with if the ticket 484 | represents user access. `null` if the ticket is an application-only ticket. 485 | - `encryptionPassword` - the ticket [encryption password](#encryptionPassword). 486 | - `options` - ticket generation [options](#ticket-options). 487 | 488 | Return value: the rsvp string or throws an error. 489 | 490 | #### `await ticket.generate(ticket, encryptionPassword, options)` 491 | 492 | Adds the cryptographic properties to a ticket and prepares the response where: 493 | - `ticket` - an incomplete [ticket](#ticket-response) object with the following: 494 | - `exp` 495 | - `app` 496 | - `user` 497 | - `scope` 498 | - `grant` 499 | - `dlg` 500 | - `encryptionPassword` - the ticket [encryption password](#encryptionPassword). 501 | - `options` - ticket generation [options](#ticket-options). 502 | 503 | Return value: the completed [ticket response](#ticket-response) object or throws an error. 504 | 505 | #### `await ticket.parse(id, encryptionPassword, options)` 506 | 507 | Decodes a ticket identifier into a ticket response where: 508 | - `id` - the ticket id which contains the encoded ticket information. 509 | - `encryptionPassword` - the ticket [encryption password](#encryptionPassword). 510 | - `options` - ticket generation [options](#ticket-options). 511 | 512 | Return value: a [ticket response](#ticket-response) object or throws an error. 513 | 514 | ## Security Considerations 515 | 516 | The greatest sources of security risks are usually found not in Oz but in the policies and 517 | procedures surrounding its use. Implementers are strongly encouraged to assess how this protocol 518 | addresses their security requirements. This section includes an incomplete list of security 519 | considerations that must be reviewed and understood before deploying Oz on the server. Most of these 520 | security considerations are the same as the 521 | [security considerations for Hawk](https://github.com/hueniverse/hawk#security-considerations), and 522 | many of the protections provided in Hawk depend on whether or not they are used and how they are 523 | used. 524 | 525 | ### Ticket and Application Hawk Credentials Transmission 526 | 527 | Oz does not provide any mechanism for obtaining or transmitting the set of shared Hawk credentials 528 | for the application. Any mechanism the application uses to obtain the Hawk credentials must ensure 529 | that these transmissions are protected using transport-layer mechanisms such as TLS. 530 | 531 | ### Plaintext Storage of Ticket and Application Hawk Credentials 532 | 533 | The ticket keys and application Hawk keys in Oz function the same way passwords do in traditional 534 | authentication systems. In order to compute the request MAC, the server must have access to the key 535 | in plain-text form. This is in contrast, for example, to modern operating systems, which store only 536 | a one-way hash of user credentials. 537 | 538 | If an attacker were to gain access to these keys—or worse, to the server's database of all such 539 | keys—he or she would be able to perform any action on behalf of the user. Accordingly, it is 540 | critical that servers protect these keys from unauthorized access. 541 | 542 | ### Entropy of Keys 543 | 544 | Unless a transport-layer security protocol is used, eavesdroppers will have full access to 545 | authenticated requests and request MAC values, and will thus be able to mount offline brute-force 546 | attacks to recover the key used. Servers should be careful to assign ticket keys and application 547 | Hawk keys that are long and random enough to resist such attacks for at least the length of time 548 | that the ticket credentials or the application Hawk credentials are valid. 549 | 550 | For example, if the credentials are valid for two weeks, servers should ensure that it is not 551 | possible to mount a brute force attack that recovers the key in less than two weeks. Of course, 552 | servers are urged to err on the side of caution and use the longest key reasonable. 553 | 554 | It is equally important that the pseudo-random number generator (PRNG) used to generate these keys 555 | be of sufficiently high quality. Many PRNG implementations generate number sequences that may appear 556 | to be random, but nevertheless exhibit patterns or other weaknesses which make cryptanalysis or 557 | brute force attacks easier. Implementers should be careful to use cryptographically secure PRNGs to 558 | avoid these problems. 559 | 560 | ### Application Redirect URI 561 | 562 | If the server redirects the RSVP to the application, the server should require a redirect URI for 563 | the application when the application is registered. This redirect URI would be used by the server to 564 | redirect back to the application with the RSVP after the user approves the grant. If the application 565 | supplies the redirect URI to the server and the server uses only this redirect URI to send the RSVP 566 | to the application, it is possible for an attacker to intercept the request from the application to 567 | server, change the supplied redirect URI to steal the RSVP, and exchange the RSVP for a user ticket 568 | if the application ticket credentials or application Hawk credentials were also stolen by the 569 | attacker. Additionally, the server requiring the registration of a redirect URI of an application 570 | adds an extra layer of security if the server redirects the RSVP to the application, because it 571 | limits what the attacker can do if he or she steals the application ticket credentials or 572 | application Hawk credentials. 573 | -------------------------------------------------------------------------------- /test/endpoints.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Code = require('code'); 6 | const Hoek = require('hoek'); 7 | const Iron = require('iron'); 8 | const Lab = require('lab'); 9 | const Oz = require('../lib'); 10 | 11 | 12 | // Declare internals 13 | 14 | const internals = {}; 15 | 16 | 17 | // Test shortcuts 18 | 19 | const { describe, it, before } = exports.lab = Lab.script(); 20 | const expect = Code.expect; 21 | 22 | 23 | describe('Endpoints', () => { 24 | 25 | const encryptionPassword = 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough'; 26 | 27 | const apps = { 28 | social: { 29 | id: 'social', 30 | scope: ['a', 'b', 'c'], 31 | key: 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn', 32 | algorithm: 'sha256' 33 | }, 34 | network: { 35 | id: 'network', 36 | scope: ['b', 'x'], 37 | key: 'witf745itwn7ey4otnw7eyi4t7syeir7bytise7rbyi', 38 | algorithm: 'sha256' 39 | } 40 | }; 41 | 42 | let appTicket = null; 43 | 44 | before(async () => { 45 | 46 | const req = { 47 | method: 'POST', 48 | url: '/oz/app', 49 | headers: { 50 | host: 'example.com', 51 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).header 52 | } 53 | }; 54 | 55 | const options = { 56 | encryptionPassword, 57 | loadAppFunc: (id) => apps[id] 58 | }; 59 | 60 | const ticket = await Oz.endpoints.app(req, null, options); 61 | appTicket = ticket; 62 | }); 63 | 64 | describe('app()', () => { 65 | 66 | it('overrides defaults', async () => { 67 | 68 | const req = { 69 | method: 'POST', 70 | url: '/oz/app', 71 | headers: { 72 | host: 'example.com', 73 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).header 74 | } 75 | }; 76 | 77 | const options = { 78 | encryptionPassword, 79 | loadAppFunc: () => apps.social, 80 | ticket: { 81 | ttl: 10 * 60 * 1000, 82 | iron: Iron.defaults 83 | }, 84 | hawk: {} 85 | }; 86 | 87 | await expect(Oz.endpoints.app(req, null, options)).to.not.reject(); 88 | }); 89 | 90 | it('fails on invalid app request (bad credentials)', async () => { 91 | 92 | const req = { 93 | method: 'POST', 94 | url: '/oz/app', 95 | headers: { 96 | host: 'example.com', 97 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).header 98 | } 99 | }; 100 | 101 | const options = { 102 | encryptionPassword, 103 | loadAppFunc: () => apps.network 104 | }; 105 | 106 | await expect(Oz.endpoints.app(req, null, options)).to.reject('Bad mac'); 107 | }); 108 | }); 109 | 110 | describe('reissue()', () => { 111 | 112 | it('allows null payload', async () => { 113 | 114 | const req = { 115 | method: 'POST', 116 | url: '/oz/reissue', 117 | headers: { 118 | host: 'example.com', 119 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).header 120 | } 121 | }; 122 | 123 | const options = { 124 | encryptionPassword, 125 | loadAppFunc: () => apps.social 126 | }; 127 | 128 | await expect(Oz.endpoints.reissue(req, null, options)).to.not.reject(); 129 | }); 130 | 131 | it('overrides defaults', async () => { 132 | 133 | const req = { 134 | method: 'POST', 135 | url: '/oz/reissue', 136 | headers: { 137 | host: 'example.com', 138 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).header 139 | } 140 | }; 141 | 142 | const options = { 143 | encryptionPassword, 144 | loadAppFunc: () => apps.social, 145 | ticket: { 146 | ttl: 10 * 60 * 1000, 147 | iron: Iron.defaults 148 | }, 149 | hawk: {} 150 | }; 151 | 152 | await expect(Oz.endpoints.reissue(req, null, options)).to.not.reject(); 153 | }); 154 | 155 | it('reissues expired ticket', async () => { 156 | 157 | let req = { 158 | method: 'POST', 159 | url: '/oz/app', 160 | headers: { 161 | host: 'example.com', 162 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).header 163 | } 164 | }; 165 | 166 | const options = { 167 | encryptionPassword, 168 | loadAppFunc: (id) => apps[id], 169 | ticket: { 170 | ttl: 5 171 | } 172 | }; 173 | 174 | const ticket = await Oz.endpoints.app(req, null, options); 175 | 176 | req = { 177 | method: 'POST', 178 | url: '/oz/reissue', 179 | headers: { 180 | host: 'example.com', 181 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).header 182 | } 183 | }; 184 | 185 | await Hoek.wait(10); 186 | await expect(Oz.endpoints.reissue(req, {}, options)).to.not.reject(); 187 | }); 188 | 189 | it('fails on app load error', async () => { 190 | 191 | const req = { 192 | method: 'POST', 193 | url: '/oz/reissue', 194 | headers: { 195 | host: 'example.com', 196 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).header 197 | } 198 | }; 199 | 200 | const options = { 201 | encryptionPassword, 202 | loadAppFunc: () => { 203 | 204 | throw new Error('not found'); 205 | } 206 | }; 207 | 208 | await expect(Oz.endpoints.reissue(req, {}, options)).to.reject('not found'); 209 | }); 210 | 211 | it('fails on missing app delegation rights', async () => { 212 | 213 | const req = { 214 | method: 'POST', 215 | url: '/oz/reissue', 216 | headers: { 217 | host: 'example.com', 218 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).header 219 | } 220 | }; 221 | 222 | const options = { 223 | encryptionPassword, 224 | loadAppFunc: () => apps.social 225 | }; 226 | 227 | await expect(Oz.endpoints.reissue(req, { issueTo: apps.network.id }, options)).to.reject('Application has no delegation rights'); 228 | }); 229 | 230 | it('fails on invalid reissue (request params)', async () => { 231 | 232 | const options = { 233 | encryptionPassword, 234 | loadAppFunc: (id) => apps[id] 235 | }; 236 | 237 | const payload = { 238 | issueTo: null 239 | }; 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).header 247 | } 248 | }; 249 | 250 | await expect(Oz.endpoints.reissue(req, payload, options)).to.reject('Invalid request payload: issueTo must be a string'); 251 | }); 252 | 253 | it('fails on invalid reissue (fails auth)', async () => { 254 | 255 | const options = { 256 | encryptionPassword, 257 | loadAppFunc: (id) => apps[id] 258 | }; 259 | 260 | const req = { 261 | method: 'POST', 262 | url: '/oz/reissue', 263 | headers: { 264 | host: 'example.com', 265 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).header 266 | } 267 | }; 268 | 269 | options.encryptionPassword = 'a_password_that_is_not_too_short_and_also_not_very_random_but_is_good_enough_x'; 270 | await expect(Oz.endpoints.reissue(req, {}, options)).to.reject('Bad hmac value'); 271 | }); 272 | 273 | it('fails on invalid reissue (invalid app)', async () => { 274 | 275 | const options = { 276 | encryptionPassword, 277 | loadAppFunc: (id) => apps[id] 278 | }; 279 | 280 | const req = { 281 | method: 'POST', 282 | url: '/oz/reissue', 283 | headers: { 284 | host: 'example.com', 285 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', appTicket).header 286 | } 287 | }; 288 | 289 | options.loadAppFunc = () => null; 290 | await expect(Oz.endpoints.reissue(req, {}, options)).to.reject('Invalid application'); 291 | }); 292 | 293 | it('fails on invalid reissue (missing grant)', async () => { 294 | 295 | const options = { 296 | encryptionPassword, 297 | loadAppFunc: (id) => apps[id] 298 | }; 299 | 300 | const grant = { 301 | id: 'a1b2c3d4e5f6g7h8i9j0', 302 | app: appTicket.app, 303 | user: 'john', 304 | exp: Oz.hawk.utils.now() + 60000 305 | }; 306 | 307 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 308 | 309 | options.loadGrantFunc = () => ({ grant }); 310 | 311 | const payload = { rsvp }; 312 | 313 | const req1 = { 314 | method: 'POST', 315 | url: '/oz/rsvp', 316 | headers: { 317 | host: 'example.com', 318 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 319 | } 320 | }; 321 | 322 | const ticket = await Oz.endpoints.rsvp(req1, payload, options); 323 | 324 | const req2 = { 325 | method: 'POST', 326 | url: '/oz/reissue', 327 | headers: { 328 | host: 'example.com', 329 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).header 330 | } 331 | }; 332 | 333 | options.loadGrantFunc = () => ({ grant: null }); 334 | 335 | await expect(Oz.endpoints.reissue(req2, {}, options)).to.reject('Invalid grant'); 336 | }); 337 | 338 | it('fails on invalid reissue (grant error)', async () => { 339 | 340 | const options = { 341 | encryptionPassword, 342 | loadAppFunc: (id) => apps[id] 343 | }; 344 | 345 | const grant = { 346 | id: 'a1b2c3d4e5f6g7h8i9j0', 347 | app: appTicket.app, 348 | user: 'john', 349 | exp: Oz.hawk.utils.now() + 60000 350 | }; 351 | 352 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 353 | 354 | options.loadGrantFunc = () => ({ grant }); 355 | 356 | const payload = { rsvp }; 357 | 358 | const req1 = { 359 | method: 'POST', 360 | url: '/oz/rsvp', 361 | headers: { 362 | host: 'example.com', 363 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 364 | } 365 | }; 366 | 367 | const ticket = await Oz.endpoints.rsvp(req1, payload, options); 368 | 369 | const req2 = { 370 | method: 'POST', 371 | url: '/oz/reissue', 372 | headers: { 373 | host: 'example.com', 374 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).header 375 | } 376 | }; 377 | 378 | options.loadGrantFunc = () => { 379 | 380 | throw new Error('what?'); 381 | }; 382 | 383 | await expect(Oz.endpoints.reissue(req2, {}, options)).to.reject('what?'); 384 | }); 385 | 386 | it('fails on invalid reissue (grant user mismatch)', async () => { 387 | 388 | const options = { 389 | encryptionPassword, 390 | loadAppFunc: (id) => apps[id] 391 | }; 392 | 393 | const grant = { 394 | id: 'a1b2c3d4e5f6g7h8i9j0', 395 | app: appTicket.app, 396 | user: 'john', 397 | exp: Oz.hawk.utils.now() + 60000 398 | }; 399 | 400 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 401 | 402 | options.loadGrantFunc = () => ({ grant }); 403 | 404 | const payload = { rsvp }; 405 | 406 | const req1 = { 407 | method: 'POST', 408 | url: '/oz/rsvp', 409 | headers: { 410 | host: 'example.com', 411 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 412 | } 413 | }; 414 | 415 | const ticket = await Oz.endpoints.rsvp(req1, payload, options); 416 | 417 | const req2 = { 418 | method: 'POST', 419 | url: '/oz/reissue', 420 | headers: { 421 | host: 'example.com', 422 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).header 423 | } 424 | }; 425 | 426 | options.loadGrantFunc = () => { 427 | 428 | grant.user = 'steve'; 429 | return { grant }; 430 | }; 431 | 432 | await expect(Oz.endpoints.reissue(req2, {}, options)).to.reject('Invalid grant'); 433 | }); 434 | 435 | it('fails on invalid reissue (grant missing exp)', async () => { 436 | 437 | const options = { 438 | encryptionPassword, 439 | loadAppFunc: (id) => apps[id] 440 | }; 441 | 442 | const grant = { 443 | id: 'a1b2c3d4e5f6g7h8i9j0', 444 | app: appTicket.app, 445 | user: 'john', 446 | exp: Oz.hawk.utils.now() + 60000 447 | }; 448 | 449 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 450 | 451 | options.loadGrantFunc = () => ({ grant }); 452 | 453 | const payload = { rsvp }; 454 | 455 | const req1 = { 456 | method: 'POST', 457 | url: '/oz/rsvp', 458 | headers: { 459 | host: 'example.com', 460 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 461 | } 462 | }; 463 | 464 | const ticket = await Oz.endpoints.rsvp(req1, payload, options); 465 | 466 | const req2 = { 467 | method: 'POST', 468 | url: '/oz/reissue', 469 | headers: { 470 | host: 'example.com', 471 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).header 472 | } 473 | }; 474 | 475 | options.loadGrantFunc = () => { 476 | 477 | delete grant.exp; 478 | return { grant }; 479 | }; 480 | 481 | await expect(Oz.endpoints.reissue(req2, {}, options)).to.reject('Invalid grant'); 482 | }); 483 | 484 | it('fails on invalid reissue (grant app does not match app or dlg)', async () => { 485 | 486 | const applications = { 487 | social: { 488 | id: 'social', 489 | key: 'werxhqb98rpaxn39848xrunpaw3489ruxnpa98w4rxn', 490 | algorithm: 'sha256', 491 | delegate: true 492 | }, 493 | network: { 494 | id: 'network', 495 | key: 'witf745itwn7ey4otnw7eyi4t7syeir7bytise7rbyi', 496 | algorithm: 'sha256' 497 | } 498 | }; 499 | 500 | // The app requests an app ticket using Oz.hawk authentication 501 | 502 | let req = { 503 | method: 'POST', 504 | url: '/oz/app', 505 | headers: { 506 | host: 'example.com', 507 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', applications.social).header 508 | } 509 | }; 510 | 511 | const options = { 512 | encryptionPassword, 513 | loadAppFunc: (id) => applications[id] 514 | }; 515 | 516 | const applicationTicket = await Oz.endpoints.app(req, null, options); 517 | 518 | // The user is redirected to the server, logs in, and grant app access, resulting in an rsvp 519 | 520 | const grant = { 521 | id: 'a1b2c3d4e5f6g7h8i9j0', 522 | app: applicationTicket.app, 523 | user: 'john', 524 | exp: Oz.hawk.utils.now() + 60000 525 | }; 526 | 527 | const rsvp = await Oz.ticket.rsvp(applications.social, grant, encryptionPassword); 528 | 529 | // After granting app access, the user returns to the app with the rsvp 530 | 531 | options.loadGrantFunc = () => ({ grant }); 532 | 533 | // The app exchanges the rsvp for a ticket 534 | 535 | let payload = { rsvp }; 536 | 537 | req = { 538 | method: 'POST', 539 | url: '/oz/rsvp', 540 | headers: { 541 | host: 'example.com', 542 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', applicationTicket).header 543 | } 544 | }; 545 | 546 | const ticket = await Oz.endpoints.rsvp(req, payload, options); 547 | 548 | // The app reissues the ticket with delegation to another app 549 | 550 | payload = { 551 | issueTo: applications.network.id 552 | }; 553 | 554 | req = { 555 | method: 'POST', 556 | url: '/oz/reissue', 557 | headers: { 558 | host: 'example.com', 559 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', ticket).header 560 | } 561 | }; 562 | 563 | const delegatedTicket = await Oz.endpoints.reissue(req, payload, options); 564 | 565 | // The other app reissues their ticket 566 | 567 | req = { 568 | method: 'POST', 569 | url: '/oz/reissue', 570 | headers: { 571 | host: 'example.com', 572 | authorization: Oz.client.header('http://example.com/oz/reissue', 'POST', delegatedTicket).header 573 | } 574 | }; 575 | 576 | options.loadGrantFunc = (id) => { 577 | 578 | grant.app = 'xyz'; 579 | return { grant }; 580 | }; 581 | 582 | await expect(Oz.endpoints.reissue(req, {}, options)).to.reject('Invalid grant'); 583 | }); 584 | }); 585 | 586 | describe('rsvp()', () => { 587 | 588 | it('overrides defaults', async () => { 589 | 590 | const options = { 591 | encryptionPassword, 592 | loadAppFunc: (id) => apps[id], 593 | ticket: { 594 | iron: Iron.defaults 595 | } 596 | }; 597 | 598 | const grant = { 599 | id: 'a1b2c3d4e5f6g7h8i9j0', 600 | app: appTicket.app, 601 | user: 'john', 602 | exp: Oz.hawk.utils.now() + 60000 603 | }; 604 | 605 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 606 | 607 | options.loadGrantFunc = () => ({ grant }); 608 | 609 | const payload = { rsvp }; 610 | 611 | const req = { 612 | method: 'POST', 613 | url: '/oz/rsvp', 614 | headers: { 615 | host: 'example.com', 616 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 617 | } 618 | }; 619 | 620 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.not.reject(); 621 | }); 622 | 623 | it('errors on invalid authentication', async () => { 624 | 625 | const options = { 626 | encryptionPassword, 627 | loadAppFunc: (id) => apps[id], 628 | ticket: { 629 | iron: Iron.defaults 630 | } 631 | }; 632 | 633 | const grant = { 634 | id: 'a1b2c3d4e5f6g7h8i9j0', 635 | app: appTicket.app, 636 | user: 'john', 637 | exp: Oz.hawk.utils.now() + 60000 638 | }; 639 | 640 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 641 | 642 | options.loadGrantFunc = () => ({ grant }); 643 | 644 | const payload = { rsvp }; 645 | 646 | const req = { 647 | method: 'POST', 648 | url: '/oz/rsvp', 649 | headers: { 650 | host: 'example.com' 651 | } 652 | }; 653 | 654 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject(); 655 | }); 656 | 657 | it('errors on expired ticket', async () => { 658 | 659 | // App ticket 660 | 661 | let req = { 662 | method: 'POST', 663 | url: '/oz/app', 664 | headers: { 665 | host: 'example.com', 666 | authorization: Oz.client.header('http://example.com/oz/app', 'POST', apps.social).header 667 | } 668 | }; 669 | 670 | const options = { 671 | encryptionPassword, 672 | loadAppFunc: (id) => apps[id], 673 | ticket: { 674 | ttl: 5 675 | } 676 | }; 677 | 678 | const applicationTicket = await Oz.endpoints.app(req, null, options); 679 | 680 | const grant = { 681 | id: 'a1b2c3d4e5f6g7h8i9j0', 682 | app: applicationTicket.app, 683 | user: 'john', 684 | exp: Oz.hawk.utils.now() + 60000 685 | }; 686 | 687 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 688 | 689 | options.loadGrantFunc = () => ({ grant }); 690 | 691 | const payload = { rsvp }; 692 | 693 | req = { 694 | method: 'POST', 695 | url: '/oz/rsvp', 696 | headers: { 697 | host: 'example.com', 698 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', applicationTicket).header 699 | } 700 | }; 701 | 702 | await Hoek.wait(10); 703 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('Expired ticket'); 704 | }); 705 | 706 | it('errors on missing payload', async () => { 707 | 708 | await expect(Oz.endpoints.rsvp({}, null, {})).to.reject('Missing required payload'); 709 | }); 710 | 711 | it('fails on invalid rsvp (request params)', async () => { 712 | 713 | const options = { 714 | encryptionPassword, 715 | loadAppFunc: (id) => apps[id] 716 | }; 717 | 718 | const grant = { 719 | id: 'a1b2c3d4e5f6g7h8i9j0', 720 | app: appTicket.app, 721 | user: 'john', 722 | exp: Oz.hawk.utils.now() + 60000 723 | }; 724 | 725 | await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 726 | 727 | options.loadGrantFunc = () => ({ grant }); 728 | 729 | const payload = { rsvp: '' }; 730 | 731 | const req = { 732 | method: 'POST', 733 | url: '/oz/rsvp', 734 | headers: { 735 | host: 'example.com', 736 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 737 | } 738 | }; 739 | 740 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('Invalid request payload: rsvp is not allowed to be empty'); 741 | }); 742 | 743 | it('fails on invalid rsvp (invalid auth)', async () => { 744 | 745 | const options = { 746 | encryptionPassword, 747 | loadAppFunc: (id) => apps[id] 748 | }; 749 | 750 | const grant = { 751 | id: 'a1b2c3d4e5f6g7h8i9j0', 752 | app: appTicket.app, 753 | user: 'john', 754 | exp: Oz.hawk.utils.now() + 60000 755 | }; 756 | 757 | await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 758 | 759 | options.loadGrantFunc = () => ({ grant }); 760 | 761 | const payload = { rsvp: 'abc' }; 762 | 763 | const req = { 764 | method: 'POST', 765 | url: '/oz/rsvp', 766 | headers: { 767 | host: 'example.com', 768 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 769 | } 770 | }; 771 | 772 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('Incorrect number of sealed components'); 773 | }); 774 | 775 | it('fails on invalid rsvp (user ticket)', async () => { 776 | 777 | const options = { 778 | encryptionPassword, 779 | loadAppFunc: (id) => apps[id] 780 | }; 781 | 782 | const grant = { 783 | id: 'a1b2c3d4e5f6g7h8i9j0', 784 | app: appTicket.app, 785 | user: 'john', 786 | exp: Oz.hawk.utils.now() + 60000 787 | }; 788 | 789 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 790 | 791 | options.loadGrantFunc = () => ({ grant }); 792 | 793 | const body = { rsvp }; 794 | 795 | const req1 = { 796 | method: 'POST', 797 | url: '/oz/rsvp', 798 | headers: { 799 | host: 'example.com', 800 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 801 | } 802 | }; 803 | 804 | const ticket1 = await Oz.endpoints.rsvp(req1, body, options); 805 | 806 | const req2 = { 807 | method: 'POST', 808 | url: '/oz/rsvp', 809 | headers: { 810 | host: 'example.com', 811 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', ticket1).header 812 | } 813 | }; 814 | 815 | await expect(Oz.endpoints.rsvp(req2, body, options)).to.reject('User ticket cannot be used on an application endpoint'); 816 | }); 817 | 818 | it('fails on invalid rsvp (mismatching apps)', async () => { 819 | 820 | const options = { 821 | encryptionPassword, 822 | loadAppFunc: (id) => apps[id] 823 | }; 824 | 825 | const grant = { 826 | id: 'a1b2c3d4e5f6g7h8i9j0', 827 | app: appTicket.app, 828 | user: 'john', 829 | exp: Oz.hawk.utils.now() + 60000 830 | }; 831 | 832 | const rsvp = await Oz.ticket.rsvp(apps.network, grant, encryptionPassword); 833 | 834 | options.loadGrantFunc = () => ({ grant }); 835 | 836 | const payload = { rsvp }; 837 | 838 | const req = { 839 | method: 'POST', 840 | url: '/oz/rsvp', 841 | headers: { 842 | host: 'example.com', 843 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 844 | } 845 | }; 846 | 847 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('Mismatching ticket and rsvp apps'); 848 | }); 849 | 850 | it('fails on invalid rsvp (expired rsvp)', async () => { 851 | 852 | const options = { 853 | encryptionPassword, 854 | loadAppFunc: (id) => apps[id] 855 | }; 856 | 857 | const grant = { 858 | id: 'a1b2c3d4e5f6g7h8i9j0', 859 | app: appTicket.app, 860 | user: 'john', 861 | exp: Oz.hawk.utils.now() + 60000 862 | }; 863 | 864 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword, { ttl: 1 }); 865 | 866 | options.loadGrantFunc = () => ({ grant }); 867 | 868 | const payload = { rsvp }; 869 | 870 | const req = { 871 | method: 'POST', 872 | url: '/oz/rsvp', 873 | headers: { 874 | host: 'example.com', 875 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 876 | } 877 | }; 878 | 879 | await Hoek.wait(10); 880 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('Expired rsvp'); 881 | }); 882 | 883 | it('fails on invalid rsvp (expired grant)', async () => { 884 | 885 | const options = { 886 | encryptionPassword, 887 | loadAppFunc: (id) => apps[id] 888 | }; 889 | 890 | const grant = { 891 | id: 'a1b2c3d4e5f6g7h8i9j0', 892 | app: appTicket.app, 893 | user: 'john', 894 | exp: Oz.hawk.utils.now() - 1000 895 | }; 896 | 897 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 898 | 899 | options.loadGrantFunc = () => ({ grant }); 900 | 901 | const payload = { rsvp }; 902 | 903 | const req = { 904 | method: 'POST', 905 | url: '/oz/rsvp', 906 | headers: { 907 | host: 'example.com', 908 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 909 | } 910 | }; 911 | 912 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('Invalid grant'); 913 | }); 914 | 915 | it('fails on invalid rsvp (missing grant envelope)', async () => { 916 | 917 | const options = { 918 | encryptionPassword, 919 | loadAppFunc: (id) => apps[id], 920 | ticket: { 921 | iron: Iron.defaults 922 | } 923 | }; 924 | 925 | const grant = { 926 | id: 'a1b2c3d4e5f6g7h8i9j0', 927 | app: appTicket.app, 928 | user: 'john', 929 | exp: Oz.hawk.utils.now() + 60000 930 | }; 931 | 932 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 933 | 934 | options.loadGrantFunc = () => null; 935 | 936 | const payload = { rsvp }; 937 | 938 | const req = { 939 | method: 'POST', 940 | url: '/oz/rsvp', 941 | headers: { 942 | host: 'example.com', 943 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 944 | } 945 | }; 946 | 947 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('Invalid grant'); 948 | }); 949 | 950 | it('fails on invalid rsvp (missing grant)', async () => { 951 | 952 | const options = { 953 | encryptionPassword, 954 | loadAppFunc: (id) => apps[id], 955 | ticket: { 956 | iron: Iron.defaults 957 | } 958 | }; 959 | 960 | const grant = { 961 | id: 'a1b2c3d4e5f6g7h8i9j0', 962 | app: appTicket.app, 963 | user: 'john', 964 | exp: Oz.hawk.utils.now() + 60000 965 | }; 966 | 967 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 968 | 969 | options.loadGrantFunc = () => ({ grant: null }); 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).header 979 | } 980 | }; 981 | 982 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('Invalid grant'); 983 | }); 984 | 985 | it('fails on invalid rsvp (grant app mismatch)', async () => { 986 | 987 | const options = { 988 | encryptionPassword, 989 | loadAppFunc: (id) => apps[id], 990 | ticket: { 991 | iron: Iron.defaults 992 | } 993 | }; 994 | 995 | const grant = { 996 | id: 'a1b2c3d4e5f6g7h8i9j0', 997 | app: appTicket.app, 998 | user: 'john', 999 | exp: Oz.hawk.utils.now() + 60000 1000 | }; 1001 | 1002 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 1003 | 1004 | options.loadGrantFunc = (id) => { 1005 | 1006 | grant.app = apps.network.id; 1007 | return { grant }; 1008 | }; 1009 | 1010 | const payload = { rsvp }; 1011 | 1012 | const req = { 1013 | method: 'POST', 1014 | url: '/oz/rsvp', 1015 | headers: { 1016 | host: 'example.com', 1017 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 1018 | } 1019 | }; 1020 | 1021 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('Invalid grant'); 1022 | }); 1023 | 1024 | it('fails on invalid rsvp (grant missing exp)', async () => { 1025 | 1026 | const options = { 1027 | encryptionPassword, 1028 | loadAppFunc: (id) => apps[id], 1029 | ticket: { 1030 | iron: Iron.defaults 1031 | } 1032 | }; 1033 | 1034 | const grant = { 1035 | id: 'a1b2c3d4e5f6g7h8i9j0', 1036 | app: appTicket.app, 1037 | user: 'john', 1038 | exp: Oz.hawk.utils.now() + 60000 1039 | }; 1040 | 1041 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 1042 | 1043 | options.loadGrantFunc = (id) => { 1044 | 1045 | delete grant.exp; 1046 | return { grant }; 1047 | }; 1048 | 1049 | const payload = { rsvp }; 1050 | 1051 | const req = { 1052 | method: 'POST', 1053 | url: '/oz/rsvp', 1054 | headers: { 1055 | host: 'example.com', 1056 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 1057 | } 1058 | }; 1059 | 1060 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('Invalid grant'); 1061 | }); 1062 | 1063 | it('fails on invalid rsvp (grant error)', async () => { 1064 | 1065 | const options = { 1066 | encryptionPassword, 1067 | loadAppFunc: (id) => apps[id], 1068 | ticket: { 1069 | iron: Iron.defaults 1070 | } 1071 | }; 1072 | 1073 | const grant = { 1074 | id: 'a1b2c3d4e5f6g7h8i9j0', 1075 | app: appTicket.app, 1076 | user: 'john', 1077 | exp: Oz.hawk.utils.now() + 60000 1078 | }; 1079 | 1080 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 1081 | 1082 | options.loadGrantFunc = (id) => { 1083 | 1084 | throw new Error('boom'); 1085 | }; 1086 | 1087 | const payload = { rsvp }; 1088 | 1089 | const req = { 1090 | method: 'POST', 1091 | url: '/oz/rsvp', 1092 | headers: { 1093 | host: 'example.com', 1094 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 1095 | } 1096 | }; 1097 | 1098 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('boom'); 1099 | }); 1100 | 1101 | it('fails on invalid rsvp (app error)', async () => { 1102 | 1103 | const options = { 1104 | encryptionPassword, 1105 | loadAppFunc: (id) => apps[id], 1106 | ticket: { 1107 | iron: Iron.defaults 1108 | } 1109 | }; 1110 | 1111 | const grant = { 1112 | id: 'a1b2c3d4e5f6g7h8i9j0', 1113 | app: appTicket.app, 1114 | user: 'john', 1115 | exp: Oz.hawk.utils.now() + 60000 1116 | }; 1117 | 1118 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 1119 | 1120 | options.loadGrantFunc = () => ({ grant }); 1121 | 1122 | const payload = { rsvp }; 1123 | 1124 | const req = { 1125 | method: 'POST', 1126 | url: '/oz/rsvp', 1127 | headers: { 1128 | host: 'example.com', 1129 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 1130 | } 1131 | }; 1132 | 1133 | options.loadAppFunc = () => { 1134 | 1135 | throw new Error('nope'); 1136 | }; 1137 | 1138 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('nope'); 1139 | }); 1140 | 1141 | it('fails on invalid rsvp (invalid app)', async () => { 1142 | 1143 | const options = { 1144 | encryptionPassword, 1145 | loadAppFunc: (id) => apps[id] 1146 | }; 1147 | 1148 | const grant = { 1149 | id: 'a1b2c3d4e5f6g7h8i9j0', 1150 | app: appTicket.app, 1151 | user: 'john', 1152 | exp: Oz.hawk.utils.now() + 60000 1153 | }; 1154 | 1155 | const rsvp = await Oz.ticket.rsvp(apps.social, grant, encryptionPassword); 1156 | 1157 | options.loadGrantFunc = () => ({ grant }); 1158 | 1159 | const payload = { rsvp }; 1160 | 1161 | const req = { 1162 | method: 'POST', 1163 | url: '/oz/rsvp', 1164 | headers: { 1165 | host: 'example.com', 1166 | authorization: Oz.client.header('http://example.com/oz/rsvp', 'POST', appTicket).header 1167 | } 1168 | }; 1169 | 1170 | options.loadAppFunc = () => null; 1171 | 1172 | await expect(Oz.endpoints.rsvp(req, payload, options)).to.reject('Invalid application'); 1173 | }); 1174 | }); 1175 | }); 1176 | --------------------------------------------------------------------------------