Skip to content

Commit

Permalink
Merge 54617e4 into 6a0fb8d
Browse files Browse the repository at this point in the history
  • Loading branch information
sangaman committed Oct 25, 2018
2 parents 6a0fb8d + 54617e4 commit 43a7293
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 14 deletions.
18 changes: 17 additions & 1 deletion README.md
Expand Up @@ -13,6 +13,7 @@
- [Optional Callbacks](#optional-callbacks)
- [Adding/Updating Methods](#addingupdating-methods)
- [Closing the Server](#closing-the-server)
- [Basic Authentication](#basic-authentication)
- [Native HTTP Server](#native-http-server)
- [Exposed Constants](#exposed-constants)
- [API Documentation](#api-documentation)
Expand Down Expand Up @@ -109,7 +110,7 @@ const rpcServer = new RpcServer({
### Adding/Updating Methods
You can register new methods or updates existing ones after the server has been created.
You can register new methods or update existing ones after the server has been created.
```javascript
rpcServer.setMethod('sum', sum);
Expand All @@ -123,6 +124,18 @@ rpcServer.close().then(() => {
}
```
### Basic Authentication
You can optionally enable [Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication) by specifying credentials when creating the server. Any requests will then require an `Authorization` header with `[username]:[password]` encoded in Base64. Note that TLS is not currently supported, therefore all traffic is unencrypted and these credentials can be stolen by anyone relaying or listening to requests. This authentication alone should not be considered secure over public networks.
```javascript
const rpcServer = new RpcServer({
username: 'johndoe',
password: 'wasspord', // ignored unless username is specified
realm: 'rpc' // realm defaults to "Restricted" if not specified
});
```
### Native HTTP Server
You can access the underlying [http.Server](https://nodejs.org/api/http.html#http_class_http_server) object with `rpcServer.server`.
Expand Down Expand Up @@ -173,6 +186,9 @@ Class representing a HTTP JSON-RPC server
| options.onRequestError | <code>function</code> | Callback for when requested methods throw errors, it is passed an error and request id |
| options.onResult | <code>function</code> | Callback for when requests are successfully returned a result. It is passed the response object and request id |
| options.onServerError | <code>function</code> | Callback for server errors, it is passed an [Error](https://nodejs.org/api/errors.html#errors_class_error) |
| options.username | <code>string</code> | Username for authentication. If provided, Basic Authentication will be enabled and required for all requests. |
| options.password | <code>string</code> | Password for authentication, ignored unless a username is also specified. |
| options.realm | <code>string</code> | Realm for authentication, ignored unless a username is also specified, defaults to `Restricted` if not specified. |
<a name="RpcServer+setMethod"></a>
Expand Down
12 changes: 12 additions & 0 deletions lib/http-jsonrpc-server.d.ts
Expand Up @@ -17,6 +17,18 @@ declare class RpcServer {
onResult: (response: any, id: string) => void,
/** Callback for server errors. */
onServerError: (err: Error) => void,
/**
* Username for authentication. If provided, Basic Authentication will be enabled and required
* for all requests.
*/
username: string,
/** Password for authentication, ignored unless a username is also specified. */
password: string,
/**
* Realm for authentication, ignored unless a username is also specified, defaults to
* `Restricted` if not specified.
*/
realm: string,
});

/**
Expand Down
25 changes: 21 additions & 4 deletions lib/http-jsonrpc-server.js
Expand Up @@ -21,14 +21,17 @@ class RpcServer {
* result. It is passed the response object and request id
* @param {function} options.onServerError - Callback for server errors, it is passed an
* {@link https://nodejs.org/api/errors.html#errors_class_error Error}
* @param {string} options.username - Username for authentication. If provided, Basic
* Authentication will be enabled and required for all requests.
* @param {string} options.password - Password for authentication, ignored unless a username is
* also specified.
* @param {string} options.realm - Realm for authentication, ignored unless a username is also
* specified, defaults to `Restricted` if not specified.
*/
constructor(options) {
this.methods = {};
this.path = '/';
this.onRequest = null;
this.onRequestError = null;
this.onResult = null;
this.onServerError = null;

if (options) {
this.applyOptions(options);
}
Expand All @@ -55,12 +58,26 @@ class RpcServer {
}
}
}

if (options.path) {
assert(typeof options.path === 'string', 'path must be a string');
assert(options.path.startsWith('/'), 'path must start with a "/" slash');
assert(/^[A-Za-z0-9\-./\]@$&()*+,;=`_:~?#!']+$/.test(options.path), 'path contains invalid characters');
this.path = options.path;
}

if (options.username) {
// Basic Authentication is enabled
assert(typeof options.username === 'string', 'username must be a string');
let stringToEncode = `${options.username}:`;
if (options.password) {
assert(typeof options.password === 'string', 'password must be a string');
stringToEncode += options.password;
}
this.authorization = Buffer.from(stringToEncode).toString('base64');
this.realm = options.realm || 'Restricted';
}

callbackNames.forEach((callbackName) => {
if (options[callbackName]) {
assert(typeof options[callbackName] === 'function', `${callbackName} must be a function`);
Expand Down
9 changes: 9 additions & 0 deletions lib/reqhandler.js
Expand Up @@ -57,6 +57,15 @@ function reqHandler(req, res) {
return;
}

if (this.authorization) {
const { authorization } = req.headers;
if (!authorization || !authorization.startsWith('Basic ') || authorization.substring(6) !== this.authorization) {
res.setHeader('WWW-Authenticate', `Basic realm="${this.realm}"`);
sendError(res, 401);
return;
}
}

const body = [];
req.on('data', (chunk) => {
body.push(chunk);
Expand Down
64 changes: 55 additions & 9 deletions test/test.js
Expand Up @@ -6,6 +6,8 @@ const request = require('supertest');
const RpcServer = require('../lib/http-jsonrpc-server');
const consts = require('../lib/consts');

const realm = 'testrealm';

function sum(arr) {
let total = 0;
for (let n = 0; n < arr.length; n += 1) {
Expand Down Expand Up @@ -96,14 +98,22 @@ describe('constructor', () => {
});
});

async function testRequest(options) {
return request(options.server)
function testRequest(options) {
const req = request(options.server)
.post(options.path || '/')
.set('Accept', options.accept || 'application/json')
.set('Content-Type', options.contentType || 'application/json')
.send(options.body)
.expect(200)
.expect('Content-Type', 'application/json');
.set('Content-Type', options.contentType || 'application/json');

if (options.authorization) {
req.set('Authorization', options.authorization);
}

const ret = req.send(options.body)
.expect(options.statusCode || 200);

return (options.statusCode && options.statusCode === 401)
? ret.expect('WWW-Authenticate', `Basic realm="${realm}"`)
: ret.expect('Content-Type', 'application/json');
}

function assertError(body, expectedCode, id) {
Expand Down Expand Up @@ -298,9 +308,7 @@ describe('request callbacks', () => {
lastResId = id;
};
const rpcServer = new RpcServer({
methods: {
sum,
},
methods: { sum },
onRequest,
onRequestError,
onResult,
Expand Down Expand Up @@ -380,3 +388,41 @@ describe('listening and closing', () => {
}
});
});

function getAuthorization(username, password) {
const authorization = Buffer.from(`${username}:${password}`).toString('base64');
return `Basic ${authorization}`;
}

describe('authentication', () => {
const reqStr = '{"jsonrpc":"2.0","id":17,"method":"sum","params":[1,2,3]}';
const username = 'test';
const password = 'wasspord';
const rpcServer = new RpcServer({
username,
password,
realm,
methods: { sum },
});

it('should reject a request without authorization', () => testRequest({
server: rpcServer.server,
body: reqStr,
statusCode: 401,
}));

it('should reject a request with invalid credentials', () => testRequest({
server: rpcServer.server,
body: reqStr,
statusCode: 401,
authorization: getAuthorization('wrong', 'credentials'),
}));

it('should accept a request with proper credentials', () => testRequest({
server: rpcServer.server,
body: reqStr,
authorization: getAuthorization(username, password),
}).then((response) => {
assertResult(response.body, 6, 17);
}));
});

0 comments on commit 43a7293

Please sign in to comment.