InteractiveFrameworks

Passport

The same sign-in, the way most of the Node world writes it: Passport strategies for the password check and for the bearer token, wrapped by @nestjs/passport into guards, with one Nest 12 trap the docs' recipe walks into.

What you'll learn

  • Describe what a Passport strategy is (a way of finding credentials in a request plus a verify step) and what @nestjs/passport turns it into (a guard)
  • Write a local strategy whose validate() checks a username and password, and a JWT strategy configured to read a bearer token with a secret and an expiry
  • Explain why a guard that extends AuthGuard('local') needs its own constructor under Nest 12, and read the DI error when it lacks one
  • Wire strategies as providers, PassportModule as an import, and a global JWT guard that honours @Public()

Lessons 1 to 4 built sign-in by hand: a service that checks a password, a guard that reads a bearer token. That is the docs' recommended path, and it is enough for many APIs. But most Node services you will open do it another way: Passport, the authentication library the docs call the most popular in the ecosystem, with hundreds of strategies for passwords, tokens, OAuth providers and more. The @nestjs/passport module folds it into the guard model you already know, so a strategy becomes a guard and a strategy's answer becomes request.user. This lesson rebuilds lesson 3's sign-in with it, so you can read either style, and meets one place where the docs' recipe does not survive Nest 12 as written.

A strategy is two things

Every Passport strategy answers two questions: where are the credentials in this request, and what makes them valid. passport-local looks in the body for username and password; passport-jwt looks wherever you tell it, usually the Authorization header, and verifies the token before you see it. The second question is yours to answer, in a validate() method: its arguments are what the strategy extracted, its return value becomes request.user, and throwing refuses the request. @nestjs/passport gives PassportStrategy(Strategy) as a base class, so a strategy is an injectable like any provider, which is how it gets AuthService:

@Injectable()
export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) {
  constructor(private readonly keys: KeysService) {
    super({ header: 'X-Api-Key', prefix: '' });
  }

  async validate(apiKey: string) {
    const owner = await this.keys.ownerOf(apiKey);
    if (!owner) {
      throw new UnauthorizedException();
    }
    return owner; // request.user
  }
}

The super() call carries the strategy's options. For passport-jwt they say where the token is (jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken()), whether an expired token is refused (ignoreExpiration: false), and the secretOrKey to verify with; the strategy then calls validate(payload) only for a token that verified, which is why that method just reshapes the payload. Nothing registers a strategy but Nest instantiating it, so each one is listed in a module's providers, beside an import of PassportModule.

A guard per strategy

AuthGuard('local') from @nestjs/passport is a guard that runs the strategy registered under that name, and the docs give each a class of its own so it can be extended:

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

On the login route, @UseGuards(LocalAuthGuard) runs the local strategy on the body; if validate() returned a user, the handler finds it in request.user and signs a token for it. Everywhere else, a guard for the jwt strategy does what lesson 1's hand-written guard did; bound with APP_GUARD and extended to check @Public() through Reflector before calling super.canActivate(), it is the same global guard with Passport underneath. A failure in either strategy, a missing password, a bad token, a validate() that threw, becomes Nest's 401 Unauthorized.

Where the recipe breaks under Nest 12

That two-line guard inherits a constructor from the mixin AuthGuard() returns, and that constructor has one parameter, the module's options, marked @Optional(). Nest reads a class's constructor parameter types by walking up to the parent, but it reads the optional markers from the class itself only. So the subclass inherits a required AuthModuleOptions dependency that nothing provides, and the app refuses to start:

Nest can't resolve dependencies of the LocalAuthGuard (?). Please make sure that the argument AuthModuleOptions at index [0] is available in the AppModule context.

The fix is to give the class its own constructor, constructor() { super(); }, so it declares no dependencies of its own. A guard that injects something anyway, JwtAuthGuard with its Reflector, has one already and is not affected. Lesson 16 of Techniques met the same rule on ConsoleLogger; it is Nest's behaviour, not Passport's.

Your task

Rebuild sign-in on Passport, keeping every rule from lesson 3.

  1. In auth/local.strategy.ts, validate() asks AuthService.validateUser(); no user is a 401.
  2. In auth/jwt.strategy.ts, configure the strategy to read a bearer token from the Authorization header, refuse expired tokens and verify with the secret; validate() returns { userId, username, roles } from the payload.
  3. In auth/local-auth.guard.ts, give the guard what Nest 12 needs. Run before you do: read the message.
  4. In auth/auth.module.ts, provide both strategies; in auth/auth.controller.ts, run the local guard on the login route.

When it fails

  • Nest can't resolve dependencies of the LocalAuthGuard (?): the constructor-less guard; see above.
  • Unknown authentication strategy "local" (logged, and every login 500 or 401): the strategy class is not in providers, so Passport never learned it exists.
  • Login is 401 for the right password: validate() returns nothing for a valid user, or validateUser compares the hash with ===.
  • Every token is 401: secretOrKey is not the secret the token was signed with, or jwtFromRequest looks somewhere else than the bearer header.
  • request.user has sub instead of userId: validate() returned the raw payload; the roles guard still works, the profile does not match.
  • Expired tokens are accepted: ignoreExpiration: true, or unset for a strategy whose default is to ignore.

Remember

  • A strategy extracts credentials and calls your validate(); what it returns is request.user, what it throws is a 401.
  • AuthGuard('name') runs the strategy of that name; strategies are providers, PassportModule is imported.
  • Under Nest 12 a guard extending AuthGuard() declares constructor() { super(); } or inherits a dependency it cannot get.
  • The JWT strategy's options say where the token is, whether expiry counts, and which secret verifies it.
Stuck? Show a hint

local.strategy.ts: const user = await this.authService.validateUser(username, password); if (!user) throw new UnauthorizedException(); return user. jwt.strategy.ts: super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: jwtConstants.secret }); validate returns { userId: payload.sub, username: payload.username, roles: payload.roles }. Both guards: constructor() { super(); } (JwtAuthGuard already has one). auth.module.ts: providers LocalStrategy and JwtStrategy. auth.controller.ts: @UseGuards(LocalAuthGuard) on login.