🐚

【罠?】Nest.js + GraphQL + Auth0でJWT認証

9 min read 2

はじめに

Nest.jsというFWでGraphQL APIを開発するのが、「楽」で「楽しい」今日この頃。ついでに認証も楽したいな、と思ってAuth0というFWを使用することにしました。
Auth0はドキュメントやSDKが豊富なので、Nest.jsでの認証もこちらの記事を読んで楽々開発できるぜ!

https://auth0.com/blog/developing-a-secure-api-with-nestjs-adding-authorization/

、、、のはずでした。そう、GraphQLでなければ。。

経緯

🐚 「この記事通りにPassportStrategy(認証フレームワーク的なもの)を拡張してAuth0の認証機構を作成して、と。」
🐚 「公式のドキュメント頼りにgetRequestメソッドもoverrideして、resolverにデコレータして、と。」

hogehoge.resolver.ts
@UseGuards(GqlAuthGuard)
@Query(()=> [HogeHoge])
async hogehoges() {
  return await this.hogehogeService.findAll()
}

🐚 「お、認証うまくいった!JWT認証楽ちんじゃん」
🐚 「ついでにRole制御も実装しよっと、」

hogehoge.resolver.ts
@UseGuards(GqlAuthGuard, RolesGuard)
@Query(()=> [HogeHoge])
@Roles(Role.Admin)
async hogehoges() {
  return await this.hogehogeService.findAll()
}

🐚 「あれ、、、どうやってもうまくいかない。。。認証後にJWT内のpayload(ユーザ情報を含むトークン情報)にアクセスできないといけないんだけど、どうやってもundefinedだぞ。。」

-----------------------------3億年後-----------------------------

🐚 「やっと公式のそれらしきissueを見つけたぞ。。。。」
🐚 「な、何だってーーーーーー!!?」

As for local strategy with GraphQL, you shouldn't use passport. I would recommend creating your own guard instead (kamilmysliwiec)
GraphQLのローカルストラテジーとしてパスポートは使わないほうがいいよ。自分でガードを作ったほうがいいんじゃないかな (Nest.js作った人)

、、、と、いうことで、ある程度、自前で認証することにしました。もし、Auth0+GraphQLでpassportStrategyを使って、RolesCurrentUserも取得できたよ!って人いたら教えてください。

ソースコード

自前でガードを実装する場合は、canActivateというメソッドを実装する必要があります。

JWT検証

こちらがまずJWTを検証するガードです。(長い)
jsonwebtokenというパッケージ(実はAuth0が作ってる)を利用して、JWTを検証します。今回は、JWT検証用の公開鍵をAuth0から取得しないといけないので、結構複雑になってます。

jwt.guard.ts
import { AUTH0_AUDIENCE, AUTH0_ISSUER_URI } from "@/environments";
import { BadRequestException, CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
import * as jwksRsa from "jwks-rsa";
import * as jwt from "jsonwebtoken";

@Injectable()
export class JwtGuard implements CanActivate {
  constructor(
  ) {}

  async canActivate(context: ExecutionContext) {
    const req = await this.getRequest(context);
    
    // headerからtokenを取得
    const authHeader = await req.headers.authorization as string;
    if (!authHeader) {
      throw new BadRequestException('Authorization header not found.');
    }
    const [type, token] = authHeader.split(' ');
    if (type !== 'Bearer') {
      throw new BadRequestException(`Authentication type \'Bearer\' required. Found \'${type}\'`);
    }

    // tokenを検証して、payloadを取得
    const payload = await this.validateToken(token);
    if (!payload) {
      throw new BadRequestException('Token not valid');
    }

    // emailとrolesを取得してrequestに追加
    req.payload = this.formatPayload(payload);
    return true
  }
  
  private const getRequest = (context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  }

   // token検証メソッド
  private validateToken = (token: string): Promise<jwt.JwtPayload> => {
    const options: jwt.VerifyOptions = {
      algorithms: ["RS256"],
      audience: AUTH0_AUDIENCE,
      issuer: AUTH0_ISSUER_URI,
    }

    return new Promise(resolve => {
      jwt.verify(token, this.getKey, options, (err, payload) => {
        if (err) {
          new BadRequestException(err);
        }
        resolve(payload);
      });
    })
  }
 
  // 検証用公開鍵をAuth0から取得するcallback関数
  private getKey = (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => {
    const client = jwksRsa({
      cache: true,
      rateLimit: true,
      jwksRequestsPerMinute: 10,
      jwksUri: `${AUTH0_ISSUER_URI}.well-known/jwks.json`,
    });
    client.getSigningKey(header.kid, (err, key: jwksRsa.SigningKey) => {
      const signingKey = key.getPublicKey()
      callback(null, signingKey);
    });
  }

  // payloadの中からrolesとemailを取得する想定
  // payloadにrolesやemailを含める方法は是非Auth0ドキュメントを読んでね
  private formatPayload = (payload: jwt.JwtPayload) => {
    return {
      roles: payload[`${AUTH0_AUDIENCE}/roles`],
      email: payload[`${AUTH0_AUDIENCE}/email`]
    }
  }
}

これは完全に余談なのですが、現在(2021-07-15)Nest.jsが用意しているJwtModuleにも公開鍵をcallbackで取得するプルリクが出てるみたいです。もしまだ出ていなければ、頑張って貢献しようかと思ってたので、ちょっと残念でしたw

Role検証

次に、rolesを検証するガードはこのようになります。role.enum.tsも作成してください。

roles.guard.ts
import { BadRequestException, CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "./auth.guard"; // これは以降にソースコードがあります
import { Role } from "@/enums/role.enum";

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  async canActivate(context: ExecutionContext) {
    // デコレータで指定したロール
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requiredRoles.length) {
      return true;
    }

    // userのロール
    const req = this.getRequest(context);
    const userRoles = await req.payload.roles;
    if (!userRoles) {
      throw new BadRequestException("Roles not found in payload");
    }

    // ロールが当てはまっているか
    const hasRole = requiredRoles.some((requiredRole) => userRoles.includes(requiredRole));
    if (!hasRole) {
      throw new BadRequestException("User roles not matched required role");
    }
    return hasRole;
  }
  
  private const getRequest = (context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  }
}

DB検証or作成

私は、JWTに含まれていたemailを使って、ユーザーをDBから取得するようにしました。また、新規ログインの場合はDBに新規ユーザのレコードを作成するようにしました。

user.guard.ts
import { UsersService } from '@/services/users.service';
import { BadRequestException, CanActivate, ExecutionContext, Injectable, InternalServerErrorException } from "@nestjs/common";

@Injectable()
export class UserGuard implements CanActivate {
  constructor(
    private usersService: UsersService
  ) {}

  async canActivate(context: ExecutionContext) {
    const req = this.getRequest(context);
    const email = await req.payload.email;
    if (!email) {
      throw new BadRequestException("Email not found in payload");
    }

    let user = await this.usersService.findOneByEmail(email)
    if (!user) {
      user = await this.usersService.create({
        email: email,
        name: "",
      })
    }

    if (!user) {
      throw new InternalServerErrorException("User was not found and created");
    }
    // requestにuserデータを追加
    req.user = user;
    return true
  }
  
  private const getRequest = (context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  }
}

ガードをまとめる

これらを作成したら、ついでにまとめてしまうのがオススメです。以下のようにまとめることで、記述を減らすことができ、かつ、JwtGuard->RolesGuard->UserGuardという順で常に認証できます。

auth.guard.ts
import { UserGuard } from './user.guard';
import { UnauthorizedException } from '@nestjs/common';
import { Role } from '@/enums/role.enum';
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { JwtGuard } from './jwt.guard';
import { RolesGuard } from './roles.guard';

export const ROLES_KEY = "roles";

export const Auth = (...roles: Role[]) => {
  return applyDecorators(
    SetMetadata(ROLES_KEY, roles),
    UseGuards(JwtGuard, RolesGuard, UserGuard),
  );
}

Usage

hogehoge.resolver.ts
@Auth(Role.Admin)
@Query(()=> [HogeHoge])
async hogehoges() {
  return await this.hogehogeService.findAll()
}

ログイン中のユーザを取得

そして、CurretnUserを取得するデコレータも作成しましょう。

currentUser.decorator.ts
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
import { getRequest } from './getRequest';

export const CurrentUser = createParamDecorator((data: unknown, context: ExecutionContext) => {
  const req = getRequest(context);
  return req.user;
});

Usage

hogehoge.resolver.ts
@Auth(Role.Admin)
@Query(()=> [HogeHoge])
async hogehoges(@CurrentUser currentUser: User) {
  return await this.hogehogeService.findAllByUser(currentUser)
}

おわりに

この記事が少しでも役に立てば幸いです!もし間違い等あれば随時指摘ください!

Discussion

公式Docより分かり易くて実用的なコードです・・・w
めちゃ助かりました!!!
CurrentUserもこんなふうに書けるんですね

日本だとまだニッチなFWですが、参考にしてくださる方がいらっしゃってよかったです!お互いEnjoy Nestjsしましょう!笑

ログインするとコメントできます