diff --git a/README.md b/README.md index ac4ca5de50..bb2c7be4c2 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,8 @@ router.get('/private-route', isAuthenticated(), (req, res) => {...}); Here are some examples: ```js +const { isAuthenticated, isAuthorized } = require("@senecacdot/satellite"); + // Authorize based on `roles` router.get('/admin', isAuthenticated(), isAuthorized({ roles: ["admin"] }), (req, res) => {...}); @@ -213,6 +215,36 @@ const { hash } = require('@senecacdot/satellite'); const id = hash('http://someurl.com'); ``` +### Create Service Token + +Services authorize requests using the `isAuthenticated()` and `isAuthorized()` middleware discussed above. +For the most part, this is meant to be used for the case of user-to-service requests: an authenticated +user passes a JWT token (acquired via the `auth` service), and uses it to request authorization to some +protected route. + +However, in cases where you need to do a service-to-service request, you can use the `createServiceToken()` +function in order to get a short-lived access token that will include the `"service"` role: + +```js +const { createServiceToken } = require('@senecacdot/satellite'); +... +const res = await fetch(`some/protected/route`, { + headers: { + Authorization: `bearer ${createServiceToken()}`, + }, +}); +``` + +The receiving service can then opt-into allowing this service to be authorized by using +the `isAuthenticated()` and `isAuthorized()` middleware like so: + +```js +const { isAuthenticated, isAuthorized } = require("@senecacdot/satellite"); + +// Allow requests with a token bearing the 'service' role to proceed +router.get('/admin-or-service', isAuthenticated(), isAuthorized({ roles: ["service"] }), (req, res) => {...}); +``` + ### Create Error The `createError()` function creates a unique HTTP Error Object which is based on [http-errors](https://www.npmjs.com/package/http-errors). diff --git a/package.json b/package.json index f26e375bc7..5f3649dbfe 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "express-pino-logger": "^6.0.0", "helmet": "4.4.1", "http-errors": "^1.8.0", + "jsonwebtoken": "^8.5.1", "pino": "^6.11.2", "pino-colada": "^2.1.0" }, @@ -42,7 +43,6 @@ "get-port": "^5.1.1", "husky": "^5.1.3", "jest": "^26.6.3", - "jsonwebtoken": "^8.5.1", "node-fetch": "^2.6.1", "prettier": "2.2.1", "pretty-quick": "3.1.0" diff --git a/src/index.js b/src/index.js index 9b2c8d6209..fb8da2e35a 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ module.exports.Satellite = require('./satellite'); module.exports.logger = require('./logger'); module.exports.hash = require('./hash'); module.exports.createError = require('http-errors'); +module.exports.createServiceToken = require('./service-token'); module.exports.Router = (options) => createRouter(options); module.exports.isAuthenticated = isAuthenticated; module.exports.isAuthorized = isAuthorized; diff --git a/src/service-token.js b/src/service-token.js new file mode 100644 index 0000000000..b37e583f26 --- /dev/null +++ b/src/service-token.js @@ -0,0 +1,22 @@ +const jwt = require('jsonwebtoken'); + +const { JWT_ISSUER, JWT_AUDIENCE, SECRET } = process.env; + +/** + * Create a short-lived service-to-service JWT, useful for authorizing + * one Telescope microservice with another. The receiving service has + * to opt into this by allowing the 'service' role. + * @returns JWT service token + */ +function createServiceToken() { + const payload = { + iss: JWT_ISSUER, + aud: JWT_AUDIENCE, + sub: 'telescope-service', + roles: ['service'], + }; + + return jwt.sign(payload, SECRET, { expiresIn: '5m' }); +} + +module.exports = createServiceToken; diff --git a/test.js b/test.js index 94a7c443f2..0ebd56398c 100644 --- a/test.js +++ b/test.js @@ -14,6 +14,7 @@ const { logger, hash, createError, + createServiceToken, } = require('./src'); const { JWT_EXPIRES_IN, JWT_ISSUER, JWT_AUDIENCE, SECRET } = process.env; @@ -427,7 +428,6 @@ describe('Satellite()', () => { isAuthorized({ authorizeUser: (user) => { expect(user).toEqual(decoded); - console.log({ user, decoded }); return user.sub === 'admin@email.com'; }, }), @@ -457,6 +457,53 @@ describe('Satellite()', () => { }); }); + test('isAuthenticated() + isAuthorized() for service token and role should work on a specific route', (done) => { + const service = createSatelliteInstance({ + name: 'test', + }); + const token = createServiceToken(); + + const router = service.router; + router.get('/public', (req, res) => res.json({ hello: 'public' })); + router.get( + '/protected', + isAuthenticated(), + isAuthorized({ roles: ['service'] }), + (req, res) => { + // Make sure an admin user payload was added to req + expect(req.user.sub).toEqual('telescope-service'); + expect(Array.isArray(req.user.roles)).toBe(true); + expect(req.user.roles).toContain('service'); + res.json({ hello: 'protected' }); + } + ); + + service.start(port, async () => { + // Public should need no bearer token + let res = await fetch(`${url}/public`); + expect(res.ok).toBe(true); + let body = await res.json(); + expect(body).toEqual({ hello: 'public' }); + + // Protected should fail without authorization header + res = await fetch(`${url}/protected`); + expect(res.ok).toBe(false); + expect(res.status).toEqual(401); + + // Protected should work with authorization header + res = await fetch(`${url}/protected`, { + headers: { + Authorization: `bearer ${token}`, + }, + }); + expect(res.ok).toBe(true); + body = await res.json(); + expect(body).toEqual({ hello: 'protected' }); + + service.stop(done); + }); + }); + test('isAuthenticated() + isAuthorized() for admin role should work on a specific route', (done) => { const service = createSatelliteInstance({ name: 'test', @@ -821,3 +868,17 @@ describe('Create Error tests for Satellite', () => { expect(testError.message).toBe('Satellite Test for Errors'); }); }); + +describe('createServiceToken()', () => { + test('should create a service token', () => { + const token = createServiceToken(); + const decoded = jwt.verify(token, SECRET); + + expect(decoded.sub).toEqual('telescope-service'); + expect(Array.isArray(decoded.roles)).toBe(true); + expect(decoded.roles).toContain('service'); + + const currentDateSeconds = Date.now() / 1000; + expect(decoded.exp).toBeGreaterThan(currentDateSeconds); + }); +});