diff --git a/package.json b/package.json index c607fd6..22debfd 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,9 @@ "jest": { "preset": "ts-jest", "testEnvironment": "node", - "rootDir": "./src" + "rootDir": "./src", + "collectCoverage": true, + "coverageDirectory": "../coverage/" }, "ts-standard": { "files": [ diff --git a/src/UnauthorizedError.ts b/src/UnauthorizedError.ts new file mode 100644 index 0000000..fcf4028 --- /dev/null +++ b/src/UnauthorizedError.ts @@ -0,0 +1,16 @@ +export class UnauthorizedError extends Error { + public inner: { message: string } + public data: { message: string, code: string, type: 'UnauthorizedError' } + + constructor (code: string, error: { message: string }) { + super(error.message) + this.message = error.message + this.inner = error + this.data = { + message: this.message, + code, + type: 'UnauthorizedError' + } + Object.setPrototypeOf(this, UnauthorizedError.prototype) + } +} diff --git a/src/__test__/authorize.test.ts b/src/__test__/authorize.test.ts new file mode 100644 index 0000000..f77d8c8 --- /dev/null +++ b/src/__test__/authorize.test.ts @@ -0,0 +1,69 @@ +import axios from 'axios' +import { io } from 'socket.io-client' + +import { fixtureStart, fixtureStop } from './fixture' + +describe('authorize', () => { + let token: string = '' + + beforeEach((done) => { + jest.setTimeout(15_000) + fixtureStart(async () => { + const response = await axios.post('http://localhost:9000/login') + token = response.data.token + done() + }) + }) + + afterEach((done) => { + fixtureStop(done) + }) + + it('should emit error with no token provided', (done) => { + const socket = io('http://localhost:9000') + socket.on('connect_error', (err: any) => { + expect(err.data.message).toEqual('no token provided') + expect(err.data.code).toEqual('credentials_required') + socket.close() + done() + }) + }) + + it('should emit error with bad token format', (done) => { + const socket = io('http://localhost:9000', { + extraHeaders: { Authorization: 'testing' } + }) + socket.on('connect_error', (err: any) => { + expect(err.data.message).toEqual( + 'Format is Authorization: Bearer [token]' + ) + expect(err.data.code).toEqual('credentials_bad_format') + socket.close() + done() + }) + }) + + it('should emit error with unauthorized handshake', (done) => { + const socket = io('http://localhost:9000', { + extraHeaders: { Authorization: 'Bearer testing' } + }) + socket.on('connect_error', (err: any) => { + expect(err.data.message).toEqual( + 'Unauthorized: Token is missing or invalid Bearer' + ) + expect(err.data.code).toEqual('invalid_token') + socket.close() + done() + }) + }) + + it('should connect the user', (done) => { + const socket = io('http://localhost:9000', { + extraHeaders: { Authorization: `Bearer ${token}` } + }) + socket.on('connect', () => { + socket.close() + done() + }) + }) +}) diff --git a/src/__test__/fixture/index.ts b/src/__test__/fixture/index.ts new file mode 100644 index 0000000..8bc99ad --- /dev/null +++ b/src/__test__/fixture/index.ts @@ -0,0 +1,48 @@ +import express from 'express' +import jwt from 'jsonwebtoken' +import { Server as HttpServer } from 'http' +import { Server as HttpsServer } from 'https' +import { Server as SocketIoServer } from 'socket.io' +import enableDestroy from 'server-destroy' + +import { authorize } from '../../index' + +interface Socket { + io: null | SocketIoServer + init: (httpServer: HttpServer | HttpsServer) => void +} + +const socket: Socket = { + io: null, + init (httpServer) { + socket.io = new SocketIoServer(httpServer) + } +} + +let server: HttpServer | null = null + +export const fixtureStart = (done: any): void => { + const options = { secret: 'aaafoo super sercret' } + const app = express() + app.use(express.json()) + app.post('/login', (_req, res) => { + const profile = { + email: 'john@doe.com', + id: 123 + } + const token = jwt.sign(profile, options.secret, { expiresIn: 60 * 60 * 5 }) + return res.json({ token }) + }) + server = app.listen(9000, done) + socket.init(server) + socket.io?.use(authorize(options)) + enableDestroy(server) +} + +export const fixtureStop = (callback: Function): void => { + socket.io?.close() + try { + server?.destroy() + } catch (err) {} + callback() +} diff --git a/src/authorize.ts b/src/authorize.ts new file mode 100644 index 0000000..810f6d7 --- /dev/null +++ b/src/authorize.ts @@ -0,0 +1,58 @@ +import jwt from 'jsonwebtoken' +import { Socket } from 'socket.io' + +import { UnauthorizedError } from './UnauthorizedError' + +interface ExtendedError extends Error { + data?: any +} + +type SocketIOMiddleware = ( + socket: Socket, + next: (err?: ExtendedError) => void +) => void + +interface AuthorizeOptions { + secret: string +} + +export const authorize = (options: AuthorizeOptions): SocketIOMiddleware => { + const { secret } = options + return (socket, next) => { + let token: string | null = null + const authorizationHeader = socket.request.headers.authorization + if (authorizationHeader != null) { + const tokenSplitted = authorizationHeader.split(' ') + if (tokenSplitted.length !== 2 || tokenSplitted[0] !== 'Bearer') { + return next( + new UnauthorizedError('credentials_bad_format', { + message: 'Format is Authorization: Bearer [token]' + }) + ) + } + token = tokenSplitted[1] + } + if (token == null) { + return next( + new UnauthorizedError('credentials_required', { + message: 'no token provided' + }) + ) + } + // Store encoded JWT + socket = Object.assign(socket, { encodedToken: token }) + let payload: any + try { + payload = jwt.verify(token, secret) + } catch { + return next( + new UnauthorizedError('invalid_token', { + message: 'Unauthorized: Token is missing or invalid Bearer' + }) + ) + } + // Store decoded JWT + socket = Object.assign(socket, { decodedToken: payload }) + return next() + } +} diff --git a/src/index.ts b/src/index.ts index 22eae88..66f83be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,73 +1 @@ -import jwt from 'jsonwebtoken' -import { Socket } from 'socket.io' - -class UnauthorizedError extends Error { - public inner: { message: string } - public data: { message: string, code: string, type: 'UnauthorizedError' } - - constructor (code: string, error: { message: string }) { - super(error.message) - this.message = error.message - this.inner = error - this.data = { - message: this.message, - code, - type: 'UnauthorizedError' - } - Object.setPrototypeOf(this, UnauthorizedError.prototype) - } -} - -interface ExtendedError extends Error { - data?: any -} - -type SocketIOMiddleware = ( - socket: Socket, - next: (err?: ExtendedError) => void -) => void - -interface AuthorizeOptions { - secret: string -} - -export const authorize = (options: AuthorizeOptions): SocketIOMiddleware => { - const { secret } = options - return (socket, next) => { - let token: string | null = null - const authorizationHeader = socket.request.headers.authorization - if (authorizationHeader != null) { - const tokenSplitted = authorizationHeader.split(' ') - if (tokenSplitted.length !== 2 || tokenSplitted[0] !== 'Bearer') { - return next( - new UnauthorizedError('credentials_bad_format', { - message: 'Format is Authorization: Bearer [token]' - }) - ) - } - token = tokenSplitted[1] - } - if (token == null) { - return next( - new UnauthorizedError('credentials_required', { - message: 'no token provided' - }) - ) - } - // Store encoded JWT - socket = Object.assign(socket, { encodedToken: token }) - let payload: any - try { - payload = jwt.verify(token, secret) - } catch { - return next( - new UnauthorizedError('invalid_token', { - message: 'Unauthorized: Token is missing or invalid Bearer' - }) - ) - } - // Store decoded JWT - socket = Object.assign(socket, { decodedToken: payload }) - return next() - } -} +export * from './authorize'