🚀

【NestJS】Hasura・NestJSをAuth0で認証処理を導入する

2022/08/19に公開約6,400字

概要

この記事ではNestJSのAPIエンドポイントとHasuraに対する認証を担うプラットフォームとしてAuth0を採用した際の導入方法についてまとめまています

https://auth0.com/jp/authentication

HasuraAuth0で保護する方法

基本的にはhasuraのドキュメント・チュートリアルが充実しているためその通りに進めれば設定は完了します

https://hasura.io/learn/ja/graphql/hasura/introduction/

Dockerで環境構築をされている場合はAUth0の公開鍵を発行して環境変数に設定する必要があるので注意が必要です

https://hub.docker.com/r/hasura/graphql-engine

環境変数に公開鍵を設定する

公開鍵の発行

こちらで説明されている通り公開鍵を発行してください

https://hasura.io/learn/ja/graphql/hasura/authentication/3-setup-env-vars-hasura/

環境変数の設定

docker-compose.yaml
version: '3.6'
services:
  postgres:
    image: postgres
    restart: always
    volumes:
    - db_data:/var/lib/postgresql/data
  graphql-engine:
    image: hasura/graphql-engine:v1.0.0-beta.6
    ports:
    - "8080:8080"
    depends_on:
    - "postgres"
    restart: always
    environment:
      HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:@postgres:5432/postgres
      HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console
      ## uncomment next line to set an admin secret
      # HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey
      # HASURA_GRAPHQL_JWT_SECRET: '発行した公開鍵を定義します'
volumes:
  db_data:

NestJSのAPIエンドポイントの保護

基本的にNestJSではHasuraで吸収できない複雑な処理のみを担ってもらいます。
Hasuraを経由する場合は前述のHasuraの保護でセキュアに保つことができます。
そこでNestJS側のエンドポイントを直接叩かれないように同様にAuth0で保護していきたいと思います。

認証で利用する値を環境変数に設定

.envに直接書くよりGCPCloud RunSecretで環境変数を定義しておく方がよさそうですが、一旦こちらに定義しています

.env
# DomainName 
AUTH0_ISSUER_URL="https://xxxx.auth0.com/"

# Identifier
AUTH0_AUDIENCE="xxxxx"

リクエスト前のGuard機能を実装

特定のリクエストを本当に通して良いのかを検証してくれるように実装していきます

実行時に存在する特定の条件 (パーミッション、ロール、ACL など) に応じて、与えられたリクエストがルートハンドラによって処理されるかどうかを決定します。これはしばしば認可 (authorization) と呼ばれます。

https://docs.nestjs.com/guards

Guardを作成する

https://docs.nestjs.com/cli/usages
# NestJS CLIで作成します
nest g gu auth/auth-guard

Guardを定義する

app/src/common/guard/auth/auth-guard.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common'
import { GqlContextType } from '@nestjs/graphql'
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'
import { Reflector } from '@nestjs/core'
import { expressjwt, GetVerificationKey } from 'express-jwt'
import { expressJwtSecret } from 'jwks-rsa'
import { ConfigService } from '@nestjs/config'
import { promisify } from 'util'

@Injectable()
export class AuthGuard implements CanActivate {
  private readonly AUTH0_AUDIENCE: string
  private readonly AUTH0_ISSUER_URL: string

  constructor(
    @InjectPinoLogger(AuthGuard.name) private readonly logger: PinoLogger,
    private readonly reflector: Reflector,
    private readonly configService: ConfigService,
  ) {
    this.AUTH0_AUDIENCE = this.configService.get('AUTH0_AUDIENCE')
    this.AUTH0_ISSUER_URL = this.configService.get('AUTH0_ISSUER_URL')
  }

  async canActivate(context: ExecutionContext): Promise<boolean> {
    if (context.getType<GqlContextType>() === 'graphql') {
      return true
    }

    // Auth0に対してJwtTokenの整合性を確認
    const checkJwtToken = await promisify(
      expressjwt({
        secret: expressJwtSecret({
          cache: true,
          rateLimit: true,
          jwksRequestsPerMinute: 5,
          jwksUri: `${process.env.AUTH0_ISSUER_URL}.well-known/jwks.json`,
        }) as GetVerificationKey,
        audience: this.AUTH0_AUDIENCE,
        issuer: this.AUTH0_ISSUER_URL,
        algorithms: ['RS256'],
      }),
    )

    try {
      await checkJwtToken(
        context.switchToHttp().getRequest(),
        context.switchToHttp().getResponse(),
      )
      return true
    } catch (e: unknown) {
      throw new UnauthorizedException(e)
    }
  }
}

APIへの保護を有効にします

app/src/app.module.ts
import { HttpAdapterHost, NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { Logger } from 'nestjs-pino'
import { AllExceptionsFilter } from './common/filter/all-exceptions.filter'

async function bootstrap() {
  // nestjs-pino https://github.com/iamolegga/nestjs-pino
  const app = await NestFactory.create(AppModule, { bufferLogs: true })
  app.useLogger(app.get(Logger))

  const adapterHost = app.get(HttpAdapterHost)
  const httpAdapter = adapterHost.httpAdapter
  const instance = httpAdapter.getInstance()
  app.useGlobalFilters(new AllExceptionsFilter(instance))

  app.enableCors({
    origin: '*',
    allowedHeaders:
      'Origin, X-Requested-With, Content-Type, Accept, Authorization',
  })

  await app.listen(3000)
}
bootstrap()

検証

@UseGuards(AuthGuard)で保護しているエンドポイントに対してid_token付きでリクエストを投げていきます

app/src/app.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common'
import { AppService } from './app.service'
import { AuthGuard } from './common/guard/auth/auth-guard.guard'

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello()
  }

  @UseGuards(AuthGuard)
  @Get('/private')
  async private() {
    return { message: '成功したお' }
  }
}

Auth0 からトークン取得するスクリプト

こちらを参考にさせていただきました🙇‍♂️

以下のスクリプトを実行し成功するとid_tokenが発行されるのでコピーしておく

https://dev.classmethod.jp/articles/auth0-nestjs-backend-sample/
#!/usr/bin/env bash

auth_url=https://xxx.auth0.com
client_id=xxxx
client_secret=xxxx
username="Auth0に登録されているアカウント"
password="アカウントのパスワード"

echo "success🎁 id_tokenを使用してください"

curl -s --request POST \
  --url ${auth_url}/oauth/token \
  --header 'content-type: application/x-www-form-urlencoded' \
  --data grant_type=password \
  --data username=xxxx \
  --data password=xxxx \
  --data client_id=xxxx \
  --data client_secret=xxxx \
echo "\n"

リクエスト

正常にid_tokenを渡すとレスポンスが返ってくることを確認できます

試しにid_tokenの一部を消して再度リクエストを行うと認証に失敗します

curl -i -X GET 'http://localhost:3000/private' -H 'Authorization: Bearer id_token'
HTTP/1.1 200 OK X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json
charset=utf-8
Content-Length: 64
Connection: keep-alive
Keep-Alive: timeout=5
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 34
ETag: W/"22-hShrnoFIPYGoHz/Bm72qqTZK7ss"
Date: Fri, 19 Aug 2022 16:25:08 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"message":"成功したお"}

おわりに

今回は直近で開発した内容を忘れないように備忘録として残しました。
これから開発される方々のお役に立てば幸いです

Discussion

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