Skip to content
This repository has been archived by the owner on Apr 3, 2024. It is now read-only.

Authentication

Erik Roberts edited this page Feb 28, 2023 · 14 revisions

NestJS comes bundled with Passport.js for authentication and we use express-session with a Redis session store via connect-redis for session storage. Passport.js can be quite bemusing, as it abstracts away all authentication steps for any method of authentication. You may wish to start by reading the Authentication documentation for NestJS.

In short, Passport is built on "strategies". A strategy is one method of logging in. For example, you can login "locally" via username and password, or you can login via OAuth with Discord. The implementation of Passport.js in NestJS applications comes in two parts:

  1. Defining different authentication strategies for Passport.js.
  2. Telling NestJS when to use those strategies via Guards.

ℹ️ All classes which you create while following this article are automatically detected and registered with NestJS's Passport implementation, so long as you register them as a provider within the AuthModule.

Strategy Definition

The NestJS Passport integration comes with a method PassportStrategy which takes in the Passport.js strategy that you're implementing and returns an extendable class.

ℹ️ Passport strategies can be downloaded from NPM. Glimpse currently uses passport-local and passport-discord. More strategies are available on the Passport.js website

For each strategy of authentication, you will have a class that extends the return result of PassportStrategy. Each strategy should implement the validate method that is responsible for verifying that correct credentials were provided, and if so, returning the corresponding user. If validation fails, you will want to interrupt the request in some way, such as throwing an error or redirecting the user to another location. Strategy options can be passed via super() in the constructor. Here is an example of a simple local login strategy.

import { Strategy } from "passport-local";
import { PassportStrategy } from "@nestjs/passport";
// ...

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
    constructor(private authService: AuthService) {
        super({ passReqToCallback: true }); // Pass the Express Request as the first parameter to `validate`
    }

    async validate(req: Request, username: string, password: string): Promise<User> {
        const user = await this.authService.attemptLogin(req, username, password);
        if (!user) {
            throw new UnauthorizedException();
        }
        return user;
    }
}

User Serialization

When our strategies return a User, Passport does not know how to store that user object in the user's session so it persists across multiple requests. We need to inform it how to do so by creating a class that extends PassportSerializer. Your class should implement the serializeUser and deserializeUser methods. This class functions the same as normal Passport.js serialization, just wrapped in a class. In Glimpse, we serialize a user as just their user ID, and then re-fetch the user with the corresponding ID during deserialization. You can read more about serialization in the Passport documentation.

Strategy Usage

The NestJS Passport integration comes with a built-in Guard AuthGuard. For each strategy of authentication, you will have a class that extends the return result of AuthGuard, which takes the name of the strategy as it's only argument (e.g. local, discord). The Guard's canActivate implementation will then call on Passport.js to initiate authentication. Thus, routes/resolvers annotated with this Guard will prompt Passport to attempt to authenticate you with the passed authentication strategy.

ℹ️ Passport strategy names are predefined within the strategy, but can be overridden by passing your desired strategy name as a second argument to PassportStrategy

Here is a brief example of a simple local username/password login AuthGuard:

@Injectable()
export class GraphqlLocalAuthGuard extends AuthGuard("local") {
    async canActivate(context: ExecutionContext): Promise<boolean> {
        const request = getRequest(context); // Get the Express Request object
        const result = (await super.canActivate(context)) as boolean;
        // Tell the Passport AuthGuard to attempt to log the user in. This will call the strategy's validate method.
        await super.logIn(request);
        return result;
    }
}

This Guard can then be used by simply annotating the login endpoint/resolver with: @UseGuards(GraphqlLocalAuthGuard).

Strategies

ℹ️ The method of authentication is stored within the loginMethod property on the request session. If a user re-authenticates, this variable will be overwritten with their latest method of authentication.

Local

Local authentication is done through GraphQL and is relatively straightforward. If a user attempts to log in and their password or username is incorrect, a UnauthorizedException is thrown.

Passwords are hashed with argon2id, and its parameters are defined within the AuthService file. Since accounts can also be created using the CLI, the parameters are also copy-pasted within the cli.ts file. Any changes to the parameters in one file should be duplicated to the other. These parameters should eventually be moved to a single point of truth.

OAuth (Discord)

While perhaps possible, OAuth authentication does not go through GraphQL, primarily because there is no need for it and it is simpler to implement through standard RESTful endpoints which redirect the user to the appropriate location. On successful OAuth, Glimpse will redirect to the URI within the OAUTH_SUCCESS_REDIRECT variable. Similarly, a failed OAuth will redirect to the OAUTH_FAIL_REDIRECT URI.

Custom Redirects

Users can pass in custom redirects that they want to be redirected to after successful OAuth with the redirect query parameter. Once OAuth succeeds, Glimpse will redirect to the URI provided instead of OAUTH_SUCCESS_REDIRECT, so long as the URI is relative or has a hostname which is contained within LOGIN_REDIRECT_HOSTS. On OAuth failure, Glimpse will redirect to the OAUTH_FAIL_REDIRECT URI still, but with the redirect appended as a query parameter.

Since OAuth is a stateful multi-request protocol, we need to persist the redirect in some way. The redirect the user provides is stored in plaintext in a cookie with the name set by config variable LOGIN_REDIRECT_COOKIE_NAME. This cookie is ephemeral and has a max lifespan of 10 minutes. However, OAuth flow usually takes only a few seconds, or a minute at most. When the user completes OAuth, the cookie is deleted. If OAuth fails, the cookie will be re-created if the user attempts to authenticate again.

OAuthException

OAuth failure redirects are initiated by throwing an OAuthException within a class that is filtered by OAuthExceptionFilter. The OAuthException constructor takes in an error code and an optional Error as its arguments. The error code is a string which is included in the redirect in the error query string parameter. The user-provided redirect is extracted from the LOGIN_REDIRECT_COOKIE_NAME cookie and also included in the redirect query string parameter, if applicable.

⚠️ OAuthExceptionFilter and OAuthException should not be used on/within GraphQL resolvers. Its behavior is undefined.