ぽぴなび

知って感動した技術情報・生活情報や買ってよかったものの雑記です。

【NestJS x Auth0】NestJSで作成したAPIにAuth0の認証/認可を追加する 〜認証編〜

前回からの続きです。

  1. NestJSで作成したAPIにAuth0の認証/認可を追加する 〜準備編〜
  2. NestJSで作成したAPIにAuth0の認証/認可を追加する 〜準備編 その2〜

今回は、以下の記事を参考に前回作成したサンプルAPIに認証機能を追加します。

Full-Stack TypeScript Apps: Developing a Secure API with NestJS

前提

  • Auth0のアカウントがあること

1. Auth0にAPIを設定する

参考部分へのリンク

  1. Auth0 ダッシュボードにアクセスし、「Applications」>「APIs」>「+ Create API」ボタンを押します。
  2. 入力欄を埋めて「Create」ボタンを押します。
    • Name: APIの名前(例: Item API
    • Identifier: APIの論理識別子 (例: https://item-api.demo.com
      • URL形式が推奨されているが実在するURLでなくとも問題ない。
      • 後から変更不可。
    • Signing Algorithm: 署名アルゴリズム(RS256)
  3. issuerとaudienceの値を控えておきます。
    • issuer: https://<AUTH0-TENANT-NAME>.auth0.com/
      • <AUTH0-TENANT-NAME>はAuth0 ダッシュボードの左上で確認できます。
    • audience: https://item-api.demo.com
      • 上で設定したIdentifierと同じです。

2. 認証メカニズム(Strategy)を定義する

NestJSに戻って認証メカニズム(Strategy)を定義します。

Strategy???

このあたりは完全に理解しているわけではないのでなんとなくの説明になります。

話は逸れますが、NestJSで認証機能を実現するためにはNestJSのGuard機能を使います。
Guardはハンドラー(Controllerで定義した各API)が実行される前に実行され、処理を実行して良いかを判断することができます。

Guards | NestJS - A progressive Node.js framework

NestJSでは認証のためのGuard(AuthGuard)が用意されているため、認証メカニズム(Strategy)さえ定義すれば以下のように簡単(?)に認証機能を実装することができます。

  @UseGuards(AuthGuard({Strategy名}))
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }

今回は参考記事のとおり、アクセストークン(JWT)による認証を実装するため、その認証メカニズムを定義する必要があります。

これから作成するStrategyなどをアプリに適用させるためにまずはmoduleを作成します。

npx nest g module authz

次にStrategyに必要なパッケージをインストールします。

npm i passport @nestjs/passport passport-jwt jwks-rsa dotenv

いよいよStrategyを作成しますが、ここはコピペです。

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
import * as dotenv from 'dotenv';

dotenv.config();

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      secretOrKeyProvider: passportJwtSecret({
        cache: true,
        rateLimit: true,
        jwksRequestsPerMinute: 5,
        jwksUri: `${process.env.AUTH0_ISSUER_URL}.well-known/jwks.json`,
      }),

      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      audience: process.env.AUTH0_AUDIENCE,
      issuer: `${process.env.AUTH0_ISSUER_URL}`,
      algorithms: ['RS256'],
    });
  }

  validate(payload: unknown): unknown {
    return payload;
  }
}

ここで、前のステップで控えておいた issuer、audienceの値が必要になるので、.envファイルを作成して環境変数を設定します。

AUTH0_ISSUER_URL=https://<AUTH0-TENANT-NAME>.auth0.com/
AUTH0_AUDIENCE=https://item-api.demo.com

最後に、上記のStrategyとPassportModuleをAuthzModuleに登録します。

import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
  providers: [JwtStrategy],
  exports: [PassportModule],
})
export class AuthzModule {}

3. AuthGuardを適用する

上記で難しい部分は終わったので、後は各APIAuthGuardを適用していきます。
今回はCreate、Update、Delete操作に対して認証を必須にしたいので、以下のように各ハンドラの上に@UseGuards(AuthGuard('jwt'))を記述します。

import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  UseGuards,
} from '@nestjs/common';
import { ItemsService } from './items.service';
import { CreateItemDto } from './dto/create-item.dto';
import { UpdateItemDto } from './dto/update-item.dto';
import { AuthGuard } from '@nestjs/passport';

@Controller('items')
export class ItemsController {
  constructor(private readonly itemsService: ItemsService) {}

  @Post()
  @UseGuards(AuthGuard('jwt'))
  create(@Body() createItemDto: CreateItemDto) {
    return this.itemsService.create(createItemDto);
  }

  @Patch(':id')
  @UseGuards(AuthGuard('jwt'))
  update(@Param('id') id: string, @Body() updateItemDto: UpdateItemDto) {
    return this.itemsService.update(id, updateItemDto);
  }

  @Delete(':id')
  @UseGuards(AuthGuard('jwt'))
  remove(@Param('id') id: string) {
    return this.itemsService.remove(id);
  }

  ~~
}

動作確認

AuthGuardを適用したAPIを実行しようとすると、Unauthorizedエラーになることが確認できました。

curl --location --request POST 'http://localhost:3000/items' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "Item D",
    "price": 4000
}'

{
    "statusCode": 401,
    "message": "Unauthorized"
}

念の為、GET /itemsUnauthorizedにならないことも確認しておきます。

4. AuthGuardを適用したAPIを実行したい

アクセストークンを渡せばAuthGuardを適用したAPIがきちんと実行できるのかを確認します。

クライアントアプリの準備

まず、アクセストークンを発行するためにクライアントアプリケーションを作成する必要があります。 (クライアントアプリを0から作る必要はなく、Auth0のサンプルアプリケーションを使用します。)

  1. Auth0 ダッシュボードにアクセスし、「Applications」>「Applications」>「+ Create Application」ボタンを押します。
  2. 入力欄を埋めて「Create」ボタンを押します。
    • Name: Sample Application
    • Application type: Single Page Web Applications
  3. 作成したApplicationのページで「Quick Start」タブを選択しお好みのフレームワークを選択します。(今回はReactを選択しました。)
  4. サンプルアプリケーションの説明ページが表示されるので「DOWNLOAD SAMPLE」ボタンからサンプルアプリケーションをダウンロードし、説明に沿ってサンプルアプリを設定します。
  5. npm installを実行した後、npm run startでアプリを立ち上げます。
    ※ ポート番号が3000でかぶっているので、NestJSかサンプルアプリのポートを変える必要があります。本記事ではNestJSを4000、サンプルアプリを3000で起動しました。
  6. サンプルアプリ画面右上の「Login」ボタンを押すと、Auth0のログイン画面が表示されるのでログイン(またはサインアップ)します。
    • Auth0 Applicationの設定が間違っているとエラー画面に飛ばされます。
  7. ログインできていれば、クライアントアプリの準備は完了です。
    • 「External API」タブが表示される。
    • 「Login」ボタンがアカウントアイコンに変わっている。など。

トークンの取得

Auth0のサンプルアプリケーションにはAPIを実行する機能があり、多少いじればクライアントアプリからAPIを実行することが可能ですが今回はめんどうなのでトークンだけ取得します。

まず、Auth0のサンプルアプリケーションに含まれているsrc/auth_config.jsonに上で設定したissuer、audienceを設定します。

次に、src/views/ExternalApi.jscallApi関数を修正して、console.logなどでトークンを取得します。
callApi関数はサンプルアプリでログイン後、「External API」タブを選択し、「Ping API」ボタンを押すと実行されます。

  const callApi = async () => {
    try {
      const token = await getAccessTokenSilently();
      console.log(token);

ちなみに、トークンの中身がどうなっているかは以下のサイトで確認できます。

jwt.io

トークンを付与してAPIを実行する

ようやくトークンが手に入ったので、APIを実行してみます。 PostmanなどでヘッダーにAuthorization: Bearer {上記で入手したトークン}を付与して実行してみると、ちゃんと実行できることが確認できました。

curl --location --request POST 'http://localhost:4000/items' \
--header 'Authorization: Bearer ${TOKEN}' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "Item E",
    "price": 4000
}'

{
    "id": "4dc38fdf-54ec-4750-b848-0518d0b1f42d",
    "name": "Item E",
    "price": 4000
}

クライアントアプリが必要なことをすっかり忘れていて思ったより長くなってしまいましたが
これで認証機能の実装は終わりです。

この時点のソースコード

github.com