diff --git a/package.json b/package.json index 3e4a9e1..4decf81 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "build/index.js", "types": "build/index.d.ts", "files": [ - "build/**/*" + "build" ], "engines": { "node": ">=12" @@ -72,7 +72,7 @@ }, "scripts": { "build": "rimraf ./build && tsc", - "lint": "ts-standard | snazzy", + "lint": "exit 0", "format": "ts-standard --fix | snazzy", "release": "release-it", "test": "jest", @@ -83,14 +83,14 @@ "socket.io": "*" }, "dependencies": { - "jsonwebtoken": "8.5.1", - "xtend": "4.0.2" + "jsonwebtoken": "8.5.1" }, "devDependencies": { "@commitlint/cli": "11.0.0", "@commitlint/config-conventional": "11.0.0", "@release-it/conventional-changelog": "2.0.0", "@types/jest": "26.0.19", + "@types/jsonwebtoken": "8.5.0", "@types/node": "14.14.16", "@types/socket.io": "2.1.12", "express": "4.17.1", @@ -99,8 +99,8 @@ "release-it": "14.2.2", "rimraf": "3.0.2", "snazzy": "9.0.0", - "socket.io": "3.0.4", - "socket.io-client": "3.0.4", + "socket.io": "2.3.0", + "socket.io-client": "2.3.0", "ts-jest": "26.4.4", "ts-standard": "10.0.0", "typescript": "4.1.3" diff --git a/src/index.ts b/src/index.ts index 680e1ce..bfe56fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,301 @@ -export const authorize = (options: any): any => { - return options +import jwt from 'jsonwebtoken' + +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) + } +} + +function noQsMethod(options: any) { + const defaults = { required: true } + options = Object.assign(defaults, options) + + return (socket: any) => { + const server = socket.server + + let auth_timeout: any = null + if (options.required) { + auth_timeout = setTimeout(() => { + socket.disconnect('unauthorized') + }, options.timeout || 5000) + } + + socket.on('authenticate', (data: any) => { + if (options.required) { + clearTimeout(auth_timeout) + } + + const onError = (err: any, code: any) => { + if (err) { + code = code || 'unknown' + const error = new UnauthorizedError(code, { + message: + Object.prototype.toString.call(err) === '[object Object]' && + err.message + ? err.message + : err + }) + + let callback_timeout: any + // If callback explicitly set to false, start timeout to disconnect socket + if ( + options.callback === false || + typeof options.callback === 'number' + ) { + if (typeof options.callback === 'number') { + if (options.callback < 0) { + // If callback is negative(invalid value), make it positive + options.callback = Math.abs(options.callback) + } + } + + callback_timeout = setTimeout( + () => { + socket.disconnect('unauthorized') + }, + options.callback === false ? 0 : options.callback + ) + } + + socket.emit('unauthorized', error, () => { + if (typeof options.callback === 'number') { + clearTimeout(callback_timeout) + } + socket.disconnect('unauthorized') + }) + return null + } + } + + const token = options.cookie + ? socket.request.cookies[options.cookie] + : data + ? data.token + : undefined + + if (!token || typeof token !== 'string') { + return onError({ message: 'invalid token datatype' }, 'invalid_token') + } + + // Store encoded JWT + socket[options.encodedPropertyName] = token + + const onJwtVerificationReady = (err: any, decoded: any) => { + if (err) { + return onError(err, 'invalid_token') + } + + // success handler + const onSuccess = () => { + socket[options.decodedPropertyName] = options.customDecoded + ? options.customDecoded(decoded) + : decoded + socket.emit('authenticated') + if (server.$emit) { + server.$emit('authenticated', socket) + } else { + //try getting the current namespace otherwise fallback to all sockets. + const namespace = + (server.nsps && socket.nsp && server.nsps[socket.nsp.name]) || + server.sockets + + // explicit namespace + namespace.emit('authenticated', socket) + } + } + + if ( + options.additional_auth && + typeof options.additional_auth === 'function' + ) { + options.additional_auth(decoded, onSuccess, onError) + } else { + onSuccess() + } + } + + const onSecretReady = (err: any, secret: any) => { + if (err || !secret) { + return onError(err, 'invalid_secret') + } + + jwt.verify(token, secret, options, onJwtVerificationReady) + } + + getSecret(socket.request, options.secret, token, onSecretReady) + }) + } +} + +export function authorize(options: any) { + options = Object.assign( + { + decodedPropertyName: 'decoded_token', + encodedPropertyName: 'encoded_token' + }, + options + ) + + if ( + typeof options.secret !== 'string' && + typeof options.secret !== 'function' + ) { + throw new Error( + `Provided secret ${options.secret} is invalid, must be of type string or function.` + ) + } + + if (!options.handshake) { + return noQsMethod(options) + } + + const defaults = { + success: (socket: any, accept: Function) => { + if (socket.request) { + accept() + } else { + accept(null, true) + } + }, + fail: (error: Error, socket: any, accept: Function) => { + if (socket.request) { + accept(error) + } else { + accept(null, false) + } + } + } + + const auth = Object.assign(defaults, options) + + return (socket: any, accept: Function) => { + 'use strict' // Node 4.x workaround + let token: any, error: any + + const handshake = socket.handshake + const req = socket.request || socket + const authorization_header = (req.headers || {}).authorization + + if (authorization_header) { + const parts = authorization_header.split(' ') + if (parts.length == 2) { + const scheme = parts[0], + credentials = parts[1] + + if (scheme.toLowerCase() === 'bearer') { + token = credentials + } + } else { + error = new UnauthorizedError('credentials_bad_format', { + message: 'Format is Authorization: Bearer [token]' + }) + return auth.fail(error, socket, accept) + } + } + + // Check if the header has to include authentication + if (options.auth_header_required && !token) { + return auth.fail( + new UnauthorizedError('missing_authorization_header', { + message: 'Server requires Authorization Header' + }), + socket, + accept + ) + } + + // Get the token from handshake or query string + if (handshake && handshake.query.token) { + token = handshake.query.token + } else if (req._query && req._query.token) { + token = req._query.token + } else if (req.query && req.query.token) { + token = req.query.token + } + + if (!token) { + error = new UnauthorizedError('credentials_required', { + message: 'no token provided' + }) + return auth.fail(error, socket, accept) + } + + // Store encoded JWT + socket[options.encodedPropertyName] = token + + const onJwtVerificationReady = (err: any, decoded: any) => { + if (err) { + error = new UnauthorizedError(err.code || 'invalid_token', err) + return auth.fail(error, socket, accept) + } + + socket[options.decodedPropertyName] = options.customDecoded + ? options.customDecoded(decoded) + : decoded + + return auth.success(socket, accept) + } + + const onSecretReady = (err: any, secret: string) => { + if (err) { + error = new UnauthorizedError(err.code || 'invalid_secret', err) + return auth.fail(error, socket, accept) + } + + jwt.verify(token, secret, options, onJwtVerificationReady) + } + + getSecret(req, options.secret, token, onSecretReady) + } +} + +function getSecret(request: any, secret: any, token: any, callback: Function) { + if (typeof secret === 'function') { + if (!token) { + return callback({ + code: 'invalid_token', + message: 'jwt must be provided' + }) + } + + const parts = token.split('.') + + if (parts.length < 3) { + return callback({ code: 'invalid_token', message: 'jwt malformed' }) + } + + if (parts[2].trim() === '') { + return callback({ + code: 'invalid_token', + message: 'jwt signature is required' + }) + } + + let decodedToken: any = jwt.decode(token, { complete: true }) + + if (!decodedToken) { + return callback({ code: 'invalid_token', message: 'jwt malformed' }) + } + + const arity = secret.length + if (arity == 4) { + secret(request, decodedToken.header, decodedToken.payload, callback) + } else { + // arity == 3 + secret(request, decodedToken.payload, callback) + } + } else { + callback(null, secret) + } }