jaunty

1.2.2 • Public • Published

Jaunty

A simple, easy-to-use JWT authentication and authorization middleware for express.js optimised for performance.

forthebadge

Build Status GitHub issues PRs Welcome GitHub license XO code style

Introduction and Rationale

I come from a Hapi.js background where most of what we want comes right out of the box or requires a little, light-weight implementation. With Express.js, however, I had the challenge of using Passport.js which is an over-kill is you want to add server-side authentication for your RESTful APIs. Honestly, you don't need so much if all you're doing is validating just a JSON web token and adding scoped authentication and/or role based authorization. Sadly I did not have any other alternatives and hence had to use whatever I got without making a fuss about it. So I did.

The code base grew in size pretty quickly and we had a lot of developers joining us; this meant that we had to explain to them how the auth framework worked, what are the good practices, etc. This was just extraneous for something as simple as outlined above.

Finally, I decided that I had no other option but to implement a middleware myself which takes care of all of this; and hence, jaunty was born. As the complexity, and knobs and switches of an application grow, so does the probability of someone messing them up. In my mind, the middleware I was creating has to be as simple as possible and at the same time as extensible as possible. A very core Hapi.js philosophy.

Jaunty has only one required parameter and the rest of them are just augmentations on validation functions and deserializations.

Intallation

npm i -S jaunty jsonwebtoken

'Bear' in mind that you need to install jsonwebtoken and express for this to work properly. They are listed, in the package.json file, as peerDependencies.

Usage - Jaunty JWT Verification

The jaunty middleware helps you automatically parse, validate and deserialize JSON Web Tokens. If you don't know what they are and how they work, I'd suggest you give the above link a read and come back.

In its simplest form, you can use jaunty like this:

const Jaunty = require( 'jaunty' );
 
// ...
app.use( Jaunty.createInstance( {
    signingSecret: 'abc'
} ) );
// ...
 
app.get( '/', function homeHandler( request, response, next ) {
 
    return response.status( 200 ).json( request.user );
 
} );

One thing you need to pay attention to is the fact that you MUST attach the jaunty middleware BEFORE you attach your routes.

In the most basic sense, you're pretty much done. That's all you need.

Options

The .createInstance( opts: Object ) method takes an options argument with the following shape:

  • signingSecret (String) - The secret used to sign the JWT.
  • validate (Function) - A function which is invoked just after the signature verification of the token is complete. You can use this to verify if the user's session is valid, etc. The function signature is: function validate( decodedToken: Object, [fn(error, data)] ). The function can return a Promise (or, in extension, can be async) or have the second parameter as a standard error-first callback. In either case, the data which has to be returned by the function should have the following shape:
    • isValid (Boolean) - Specifies if the provided token has passed external, probably non-cryptographic validation like session ID checks, etc.
    • payload (Object) [null] - The custom deserialized version of the JWT payload provided to the function. This can be useful in case you are fetching some additional data from your database (say, for example, the authentication/role scope). If this property is present, Jaunty will use it as the deserialized form of the user object and assign it to your specified attachments (documented below).
  • ignoreAuthentication (Set) - A set of routes which the middleware should ignore and allow to pass without auth.
  • attachments (Object) - An object which contains:
    • request (String) [user] - The name of the property on the request object which will contain the decoded and deserialized payload.
    • response (String) [null] - Similarly, the name of the propery on the response object.

Handling Jaunty Errors

Jaunty exposes a common base error type called AuthorizationError which acts as the base class for all the errors emitted by Jaunty. Following are the errors emitted by Jaunty at various points in time:

  • BadSchemeError - this error is thrown by Jaunty when, for a required route, no Authorization header is provided or when the header is not in the form of Authorization: Bearer <Token>.
  • BadTokenError - thrown when the JWT token is malformed and/or can not be parsed.
  • UnauthorizedError - thrown when the user isn't authorized/authenticated to access the route.

All of these errors are exported in the Jaunty module as Jaunty.Errors. A simple example handler for errors can have the following form:

const Jaunty = require( 'jaunty' );
 
// ... basic config ...
 
app.use( Jaunty.createInstance( {
    signingSecret: 'My_SECRET!'
} ) );
 
// ... other middlewares ...
 
app.get( '/', handler );
 
// ... other routes ...
 
app.use( function baseErrorHandler( err, req, res, next ) {
 
    //
    if ( err instanceof Jaunty.Errors.UnauthorizedError ) {
 
        return res.status( 403 ).json( {
            errors: [
                {
                    message: 'You are not allowed to access this route.'
                }
            ]
        } );
 
    } else if ( err instanceof Jaunty.Errors.AuthorizationError ) {
 
        return res.status( 401 ).json( {
            errors: [
                {
                    message: 'You are not authenticated.'
                }
            ]
        } );
 
    }
 
    return next();
 
} );
 
// ... bootstrapping code ...

Take note of the two things we are doing here and their order; the first construct checks specifically for UnauthorizedError whilst the second one catches everything else. Make sure that the block to check for specific errors is always at the last to avoid confusion.

Usage - Jaunty ACL

With release 1.1.0, Jaunty comes with its own ACL (Access Control List) module which is, much like Jaunty, super-simple to use. To get started with the ACL, you can do something like the following:

const Jaunty = require( 'jaunty' );
const aclProvider = Jaunty.createACL();
 
// Use the Jaunty to verify JWTs at a router/application level.
app.use( Jaunty.createInstance( {
    signingSecret: 'WHAT_EVER_STRING',
    ignoreAuthentication: new Set( [ '/login' ] )
} ) );
 
// Now you can use the ACL like so:
// Per route level
app.get( '/', aclProvider.hasPermissions( 'user:write' ), function handleGet() { ... } );
 
// Per router level
const adminRouter = express.Router();
 
adminRouter.use( aclProvider.hasPermissions( 'admin' ) );
adminRouter.get( ... );
 
app.use( '/admin', adminRouter );

Options

The .createACL() function takes an object as its options. There are just two properties on the options object:

  • attachmentPath (String) [user] - This is the path to the User's object on express's request.
  • permissionsPath (String) [permissions] - This is the path to the permissions property on the User object.

With the defaults, permissions is at request.user.permissions. I hope this makes sense.

After you execute createACL(), an object is returned which contains just one function (yet again) called hasPermissions([permissions]). This hasPermissions() functions is responsible for ultimately compiling and spitting out the middleware which validates the routes for permissions.

You have a couple of ways in which you can specify permissions to the hasPermissions() function.

  • hasPermissions( 'permission1', 'permission2' ) - this translates to: make sure the user has permission1 and permission2;
  • hasPermissions( [ 'permission1' ], [ 'permission2', 'permission3' ] ) - this translates to: make sure the user has either permission1 or permission2 and permission3.

Similarly, your permissions object on the user can be either an array of string or a space separated OAuth style scope. Which is uber-jargon to say:

 
// Type one
const user = {
    permissions: [ 'read', 'write' ]
};
 
// Type two
const user = {
    permissions: 'admin:read admin:write'
};

Examples

Ignore Routes

In an API, there needs to be a way to get the authentication token; by its very design, this route needs to be open for use without any form of user-delegated authentication. This is supported in Jaunty by using the ignoreAuthentication property while creating and initializing the instance.

// ... other express-related stuff ...
app.use( Jaunty.createInstance( {
    signingSecret: 'WHAT_EVER_STRING',
    ignoreAuthentication: new Set( [ '/login' ] )
} ) );
// ... other express-related stuff ...

Now, every request sent to the /login route will be open and not require any form of validation.

Complicated Ignore Patterns

The ignoreAuthentication property is good for simple rules like the ones defined above. What about parameterized paths or what about when you want to ignore a specific path if it matches a specific HTTP method? For that, we are using the excellent express-unless.

Look up its documentation to read more. As a quick example, if you want to open the /test/:id path but keep /test closed, you can do something like the following:

// ... other express-related stuff ...
app.use( jwtLock.unless( {
    path: [ /\/test\/*./ ]
} ) );
// ... dragons ...

Validate Sessions

Once the cryptographic verification of the token is done, an optional callback function can be supplied which checks if the session for that token is still valid. The validate property provided to Jaunty takes the form function( token: Object, [function (error, data)] ). Let's see a quick example of that in actions:

In the app.js file, you can have the middleware defined as follows:

// app.js
 
// ... express stuff ...
app.use( Jaunty.createInstance( {
    signingSecret: 'MY_SUPER_SECRET',
    validate: require( './validate' )
} ) );
 
// ... other middlewares ...
// ... error handlers ...
 
module.exports = app;

And in your validate.js file you can have something like the following:

// validate.js
 
const { Session } = require( '../models' );
 
module.exports = async function validateJWT( token ) {
 
    try {
 
        /*
         * The token is a COMPLETE decode of the data which
         * means that it includes the header. In general,
         * following is the shape of a JWT:
         *      {
         *          header: Object,
         *          payload: Object,
         *          signature: String | Buffer
         *      }
         */
 
        // You can also detructure it in the argument definition.
        const { payload } = token;
        const sessionData = await Session.findById( payload.sessionId );
 
        if ( !sessionData ) {
 
            return { isValid: false };
 
        }
 
        if ( sessionData.userId === payload.userId ) {
 
            return {
                isValid: true,
 
                /*
                 * You can also, optionally, specify a payload.
                 * If provided, Jaunty will use that when it
                 * attaches the deserialized user to the
                 * attachment you have provided.
                 */
                payload: {}
            }
 
        }
 
    } catch ( error ) {
 
        // Jaunty will catch it and respond.
        throw error;
 
    }
 
};

What Jaunty will effectively do is that once the provided JWT gets cryptographically validated, it'll call the validate() method with the entire token. You can now run your own checks and add whatever logic you deem fit. Finally, return an object which contains { isValid: Boolean } and optionally the modified version of the token payload you want.

Which means that whatever you return in the payload property will be what you can access from request.user.

Advice on Security

Just to make your application extra secure, please do not store the signing secret in any unsafe/insecure place and make sure that you rotate it regularly.

In case you want better security, think about using asymmetric key pairs (support coming soon) for signing and verifying JW tokens.

One of the best methods of managing sensitive cryptographic keys is to use AWS Secrets Manager along with AWS Key Management Service.

For systems not so heavy on compliance, you can get away with dynamic environment variables written in your .env file at the time of build on the CI. This comes with its own risk since the hardware on which the CI builder runs is multi-tenacy.

Contributing

In case you have a feature in mind or a bug fix, feel free to send a PR! And don't worry; your PRs won't be ignored: at all.

Style Guide

The project uses the xo coding style with a few modifications.

To aid with changelog generation and release management, we urge everyone to use the conventional-changelog format. In case you don't want to pollute the system with another global binary, you can just follow the commit style while writing your message. (Personally, I find that to be faster.)

License

Copyright 2019 Shreyansh Pandey

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Package Sidebar

Install

npm i jaunty

Weekly Downloads

2

Version

1.2.2

License

MIT

Unpacked Size

38.1 kB

Total Files

12

Last publish

Collaborators

  • labsvisual