@eropple/nestjs-auth
v0.6.0
Published
(NestJS 7+ only) Comprehensive handling of authentication and authorization for NestJS.
Downloads
271
Readme
@eropple/nestjs-auth
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'sany
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 yourreq.locals
instance. Thanks to Brian Kracoff @ Hydrow for the contribution.
0.4.0
- Added
unauthorizedResponse
andforbiddenResponse
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
andHttpAuthzInterceptor
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 analways
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 toIdentifiedBill
), 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 NodeJShttp
package) into anIdentifiedExpressRequest
, which we define as adding theidentity
property. This property is anIdentityBill
, 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 returnfalse
, 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 resourceuser
, implied to be "the current user".user/edit
- Allows editing the singleton resourceuser
, such as editing the user's profile.user/session/list
- Allows listing all sub-resourcesession
s within the singleton resourceuser
. (If you made thisuser/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 resourceuser
. Implies bothuser/view
anduser/edit
, but would not implyuser/session/list
(it would implyuser/session
, but as we just discussed that's not a valid scope.)user/**/*
(grant, not scope) - Allows any action onuser
or subresource.file/create
- Allows the creation of a newfile
resource (POST). Presumably, the response will include the ID of that file.file/12345/view
- Allows viewing thefile
resource with id12345
.file/*/view
(grant, not scope) - Allows viewing of anyfile
resource, but does not allowfile/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.