@eropple/nestjs-auth

(NestJS 7+ only) Comprehensive handling of authentication and authorization for NestJS.

Usage no npm install needed!

<script type="module">
  import eroppleNestjsAuth from 'https://cdn.skypack.dev/@eropple/nestjs-auth';
</script>

README

@eropple/nestjs-auth

npm version

Current Status

0.6.x is being used, in anger, on multiple production apps, at my current employer and by other NestJS users.

Recent Changes

0.6.0

  • Now requires NestJS 7. Sorry about that. They broke compatibility.
  • Fixed breaking changes going to NestJS 7. NestJS 6 should remain on 0.5.2.

0.5.2

  • Bug fix: not awaiting the context at the root of the authz tree. I will probably rethink the types expressing that API to make this easier to catch in the future; right now it's any and that is a smell.
  • Bug fix: when using multiple scopes on the same endpoint, the testing of subsequent endpoints would result in re-setting request.locals. It no longer does this.

0.5.1

  • @AuthzScope() is now stackable. If you use it multiple times on the same handler, the scopes checked will be the union of all of them.
  • @AuthzAdoptScopeFrom() added. It takes a controller and the name of a handler (which are typechecked, even though the syntax is a bit gross) and unions the scopes specified by that handler with any scopes specified for the current one. Thanks to Brian Kracoff @ Hydrow for the idea.
  • 0.5.1 fixes a failure-to-start bug in some NestJS environments. Pulled 0.5.0.

0.4.2

  • Added @AuthnSkip decorator. This completely omits the endpoint from any checking, including any context functions that may attach data to your req.locals instance. Thanks to Brian Kracoff @ Hydrow for the contribution.

0.4.0

  • Added unauthorizedResponse and forbiddenResponse to the interceptor's options. These allow you to customize the output of 401s and 403s emitted by @eropple/nestjs-auth such that they can be predictable shapes in your codebase. This feature is designed to be used with @eropple/nestjs-openapi3 so that you can easily provide a typed schema for your errors, but the world is your oyster!
  • Minor doc improvements.

0.3.0 / 0.3.1 (bug fix)

  • nestjs-auth now expects template arguments around principals and (optionally) credentials. Take a look at the example for details.
  • Replaced HttpAuthnInterceptor and HttpAuthzInterceptor with a single interceptor, HttpAuthxInterceptor. This is because NestJS offers no explicit way to guarantee that two request-scoped interceptors will run in the correct order. Order-of-declaration works but I don't consider it sufficiently reliable and it's easy enough to pass an always right as part of the tree if you wish to opt out of authz.

0.2.2

  • Continued extending type system, this time on the authn side, to reduce the number of places where programmers have to trust their feeble brainmeats to do the right thing.

0.2.1

  • Added generic types (with concrete default parameters) to ease type safety concerns when writing things like rights trees. In 0.3.0, the top-level generic parameters (things like HttpAuthnInterceptor) will lose their default values (where they currently map directly to IdentifiedBill), to encourage consumers to define their own top-level types and use them in their applications.

Introduction

Authentication and authorization on the web sucks.

There, I said it.

I don't mean the initial login process, though that kinda stinks too--we've got the awesome Passport library to help us out there, though, and it really isn't that hard to write even OIDC or SAML correctly by hand. What sucks is everything past that point. There are some interesting tools out there like Open Policy Agent that are great if you want to wrangle a microserviceful universe--but most applications don't need microservices, most applications don't need to add a step to either their dev setup or their prod environment to go configure a spooky-action-at-a-distance service wedged into their environment--and most developers need something that gets out of the way so they can concentrate on building the thing they actually want to build.

In my NestJS travels, I haven't found something that hits the important bits:

  • Do the simplest thing that can possibly work. NestJS encourages some stuff that I have a pretty big problem with; in particular, the way the NestJS docs lead users by the hand down towards JWT--which is a minefield full of rabid alligators wielding rakes for you to step on, don't use JWT unless you know exactly why you need JWT and even then use something better, like PASETO, intead--makes me uncomfortable.
  • Fall into correctness. It should be hard to do the wrong thing. By opting into @eropple/nestjs-auth, you should have a secure-by-default auth scheme and you should have to explicitly opt out, whether to a less secure mode for a particular handler or to a completely unsecured mode. (This is the same principle behind [@eropple/nestjs-data-sec](https://github.com/eropple/nestjs-data-sec), for what it's worth.)
  • Support resource-based access control. This is a big one to me. So many libraries out there want to give you simple role-based access control, and for a lot of stuff that's fine, but if you're building anything with any kind of multi-tenancy that's just not going to fly. And a lot of the solutions that do support resource-based access control come packed with some heavy policy tooling that requires mapping from the application's domain modeling to that policy language. (If you want a policy language, I won't judge. But you should make that decision yourself and not have it pushed onto you by the thing that handles serving 401s and 403s.)

This is my take on attacking the problem. Not "once and for all," but maybe "once more for the time I'm using NestJS".

One important note: this package is only tested to work with Express. Fastify support is out of my personal scope for it; if you'd like it, I am happy to accept PRs.

Installation

It's an NPM package. It's called @eropple/nestjs-auth. Wield your package manager of choice and install it.

Just remember that you gotta have NestJS 6.5 or newer to make this work.

Usage

Before you read all this: code can speak for itself. Please consider checking out @eropple/nestjs-auth-example; it is exhaustively commented and has end-to-end tests that demonstrate @eropple/nestjs-auth's completeness.

@eropple/nestjs-auth provides the building blocks, but because of its focus on extensibility--not prescribing to you how your domain objects should work--I'm afraid you're going to have to do the wire-up yourself. Don't worry: it's easy, and if it shows you some stuff you're unfamiliar with you're going to benefit from learning how it works for your own code.

(As an aside: I've been asked why this is an interceptor rather than a guard. That's because NestJS puts guards before interceptors, and if this was written as a guard it'd mean that you couldn't put a logging interceptor around requests that are rejected. It's harder to debug and harder to reason about.)

How It Works

There's perilously little magic in @eropple/nestjs-auth. It provides one interceptor, HttpAuthxInterceptor, which needs to be attached to a module for injection (we'll cover that later). These interceptors use their startup config and a set of decorators applied to handler methods to determine who's allowed to access what.

NOTE: Version 0.2.x used two interceptors. This proved to be not-that-great if you wanted to use a request-scoped nestjs-auth (for example, you use request-scoped services in your rights tree), because even after the NestJS 6.5 fixes that allow you to properly do request-scoped interceptors the ordering of them is undefined. In practice, they load in the order they're declared, but I don't really want to rely on that and I don't think you should either, so 0.3.0 collapses them into a single interceptor.

Authentication

  • The authn step retrieves an identity (PASETO token, session token string, etc.) through a user-defined function. This identity is stashed on the request object, turning it from an Express Request (from the NodeJS http package) into an IdentifiedExpressRequest, which we define as adding the identity property. This property is an IdentityBill, which contains a principal ("who is this?"), a credential ("what says that they're them?"), and a set of scopes that we'll use to authorize access to some resources. If the user function determines that the identity is invalid--it's been revoked or has expired over time, for example--then that function can return false, and the requestor will immediately receive 401 Unauthorized.
  • The authz step checks that identity. If no identity was found, it attaches to the request an anonymous identity, which can be given a set of scopes of its own.
  • HttpAuthnInterceptor inspects the controller and its handler. By default, all endpoints require authentication, but you can decorate your handlers with @AuthnOptional() to allow anonymous identities, with @AuthnDisallowed() to require them or with @AuthnSkip() to skip the checks entirely. If the handler's requirement matches up with the identity on the request, the request continues; otherwise, the response is a 401 Unauthorized.

|| @AuthnRequired | @AuthnOptional | @AuthnDisallowed | @AuthnSkip | |-|-|-|-|-| | Good Auth |✅|✅|❌|✅| | Bad Auth |❌|❌|❌|✅| | No Auth |❌|✅|✅|✅|

Authorization

@eropple/nestjs-auth relies on three concepts for authorization: scopes, grants, and rights.

Scopes

Zero or more scopes are attached to every handler method by using the @AuthzScope() decorator. An identity that has both a grant and a right to that scope is authorized to access the handler's endpoint. A list of example scopes can be found below.

A method with zero scopes attached to it will always be allowed so long as the identity authenticates correctly.

A method with no scope decorator attached to it will, once it hits the HttpAuthzInterceptor, throw a 500 Internal Server Error.

Grants

Scopes provided to an identity are called grants. If a handler uses a scope that is included in the identity's grants, then the identity is authorized to use that handler. Since we use [nanomatch](https://www.npmjs.com/package/nanomatch), you can use both * and ** (globstars) in your identity's grants to expand the matches allowed.

Examples of Scopes and Grants

Here are some examples of hypothetical scopes and grants, based on different resources:

  • user/view - Allows viewing--for example, viewing private information such as email address--of the singleton resource user, implied to be "the current user".
  • user/edit - Allows editing the singleton resource user, such as editing the user's profile.
  • user/session/list - Allows listing all sub-resource sessions within the singleton resource user. (If you made this user/session and implied the /list part, you'd have surprising behavior with the next one.)
  • user/* (grant, not scope) - Allows any action on the singleton resource user. Implies both user/view and user/edit, but would not imply user/session/list (it would imply user/session, but as we just discussed that's not a valid scope.)
  • user/**/* (grant, not scope) - Allows any action on user or subresource.
  • file/create - Allows the creation of a new file resource (POST). Presumably, the response will include the ID of that file.
  • file/12345/view - Allows viewing the file resource with id 12345.
  • file/*/view (grant, not scope) - Allows viewing of any file resource, but does not allow file/create.
  • **/* (grant, not scope) - Superuser glob; allows any access to any resource. A login scope, where you're logging in directly, will typically have this permission unless you're implementing a GitHub-style "sudo pattern".
Rights

While grants are provided by (or perhaps "on behalf of") the user, rights determine what the user is actually allowed to access on a system level. For example, a user might give an API token the scope file/12345/view--but that doesn't mean that the user is allowed to view file 12345.

To that end, you must pass into HttpAuthzInterceptor what we refer to as the rights tree. This is an object tree; children map to values in the children If a scope is valid, its corresponding node in the rights tree will have a right function that returns boolean | Promise<boolean> so that you can check your source of truth to ensure that the identity actually does have the right to access the OAuth2 scope that you've granted.

Once we've gotten to the authz step, you can take as guaranteed that we have added a locals field to the request. As such, each node may have a context method that can test against the current request, potentially to short-circuit and return 403 early but also to potentially store request-local data for other uses.. For example, if a path segment is a wildcard that represents a file ID and the file ID doesn't exist, the context method can return a falsy value to tell the requestor that they are unauthorized; if it does exist, the context method can attach the file entity to request.locals (which can then be used by deeper parts of the rights tree or be used for parameter injection in your handlers). context methods never positively affirm a right, however; only a right method can do that.

The above example of a nonexistent file is a good time to note that neither context nor right methods do not handle exceptions; throwing an HttpException will cause the response to be a 500 Internal Server Error. This is a conscious decision--it might be tempting to say that we should return a 404 here, but returning a 404 here allows a potential attacker to identify when a resource exists even if they don't have access to it. So we don't make that an option.

You can see an example of a rights tree in Module Injection, below.

Module Injection

Your application's module, which we'll call MyAuthModule for the rest of this README, will need to tell NestJS how to build a HttpAuthxInterceptor. We do this with a factory provider; you can see how to do this in the example project's module injection.

One helpful note: you might want to refer to NestJS's documentation on circular dependencies when writing this; forward references are a little tricky.

Setting Up

Once you've got your module wired up, you need to attach the authentication and the authorization interceptors to your application. There are two ways to do this; one is way better than the other.

The Good, Happy Path That Leads To Success

It's a little long to put here. Please take a look at the example project.

I'm of two minds about global interceptors and guards. You have to replicate them in testing situations (please remember to add this to your E2E tests, too!) and that can lead to some confusion. On the other hand, this is the only way to assert "everything is authenticated and authorized by default".

Future Work

  • socket.io authorization/authentication
  • tests - the tests for this exist in the original app it was extracted from, they need to be cleaned up and made available here.