🔐

NestJSとcognitoでJWT認証を実現するサンプル

2022/04/07に公開

NestJSとJWT認証で調べて出てくるのは、JWTを自分たちで発行しているやつが多く、Cognitoなど外部がトークン発行してくれる系の認証サンプルが少なかったので、共有します。

公式サイトでは認証に関するページは以下のリンクです。
https://docs.nestjs.com/security/authentication

目標

以下のようにappコントローラーにjwt認証を追加します。このコントローラーにリクエストを送っても、有効なJWTをAuthenticationヘッダに付与しなければ、401エラーで弾かれる仕様です。

app.controller.ts
@UseGuards(JwtGuard)
@Controller()
export class AppController {

  @Get('hello')
  testGet(): string {
    return 'Hello!';
  }
}

インストール

まず以下をインストールしてください。

 $ npm install --save @nestjs/passport passport jwks-rsa passport-jwt
 $ npm install --save-dev @types/passport-jwt

@nestjs/passport はNestJSでパスポートモジュールを扱うためのものです。
jwks-rsaはcognitoのサーバーからキーを取得して署名があってるか確認するために利用します。ここは自分でも実装できるので後ほどこのライブラリを使わない方法を紹介します。
passport-jwtはJWTストラテジーを作成するために利用します。

作るもの

今回作るものは主に3つです。

  • JWTガード
  • passportに対応したJWTストラテジー
  • authモジュール

NestJSにおけるガードがよくわからない人はこちらの公式サイトの解説を一読することをおすすめします。
https://docs.nestjs.com/guards

ディレクトリ構成は以下のとおりです。

.
├──app.module.ts
├──app.controller.ts
└──auth
   ├──auth.module.ts
   ├──guard
   │  └──jwt.guard.ts
   └──strategy
      └──jwt.strategy.ts

1つずつ解説していきたいと思います。

app.module.ts

ルート部分のモジュールです。今回、jwt認証のためのコードは特にありません。

app.module.ts
import {
  Module,
  NestModule,
  MiddlewareConsumer,
  RequestMethod,
} from '@nestjs/common';

import { AuthModule } from './auth/auth.module';
import { AppController } from './app.controller';

@Module({
  imports: [AuthModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

app.controller.ts

ルート部分のコントローラーです。このコントローラーにJWT認証をつけたいと思います。

app.controller.ts
import {
  Controller,
  Get,
  UseGuards,
} from '@nestjs/common';
import { JwtGuard } from './auth/guard/jwt.guard';

// JWT認証をこのコントローラー全体に適応
@UseGuards(JwtGuard)
@Controller()
export class AppController {

  @Get('hello')
  testGet(): string {
    return 'Hello!';
  }
}

auth/auth.module.ts

いよいよメインです。authモジュールです。NodeJSにおける代表的な認証ライブラリであるPassport.jsをNestJS向けにカスタマイズした@nestjs/passportを利用します。

auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';

import { JwtStrategy } from './strategy/jwt.strategy';
// パターン2を使いたいときはコメントインする。
// import { ManualJwtStrategy } from './strategy/jwt.manual.strategy';

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

auth/guard/jwt.guard.ts

NestJSのガードをJWTように作ります。コントローラーで@UseGuardsデコレーターと一緒に使えば無効な認証を弾くことができるようになります。

auth/guard/jwt.guard.ts
import { AuthGuard } from '@nestjs/passport';

// AuthGuard('jwt')の引数の値は
// jwt.strategy.tsのPassportStrategy(Strategy, 'jwt')に合わせる。
export class JwtGuard extends AuthGuard('jwt') {
  constructor() {
    super();
  }
}

[パターン1 ライブラリで検証] auth/strategy/jwt.strategy.ts

JWT認証の本体です。passport-jwtStrategyがコンストラクタで指定したsecretOrKeyProviderをもとに、JWTをチェックしてくれます。有効なJWTだけvalidateにデコードして渡してくれます。無効なJWTはその時点で401エラーを返します。

ただ厄介なのは、なぜ401エラーなのかの詳細なログを残してくれないので、トークンが悪いのか、他の実装がわるのかわかりません。その場合は次のパターン2の実装のやり方をすることでデバッグしやすいかもです。

auth/strategy/jwt.strategy.ts
import {
  Injectable,
  Logger,
} from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { passportJwtSecret } from 'jwks-rsa';
import { ExtractJwt, Strategy } from 'passport-jwt';

export interface Claim {
  sub: string;
  email: string;
  token_use: string;
  auth_time: number;
  iss: string;
  exp: number;
  username: string;
  client_id: string;
}

const COGNITO_CLIENT_ID = 'hogehoge';
const COGNITO_REGION = 'ap-northeast-1';
const COGNITO_USERPOOL_ID = 'fugafuga';

const AUTHORITY = `https://cognito-idp.${COGNITO_REGION}.amazonaws.com/${COGNITO_USERPOOL_ID}`;

// PassportStrategy(Strategy, 'jwt')部分の第一引数は
// jwt.guard.tsでのAuthGuard('jwt')に合わせる。
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  private logger = new Logger(JwtStrategy.name);

  constructor() {
    super({
      // ヘッダからBearerトークンを取得
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      // cognitoのクライアントidを指定
      audience: COGNITO_CLIENT_ID,
      // jwt発行者。今回はcognito
      issuer: AUTHORITY,
      algorithms: ['RS256'],
      // もし自分がjwt発行してるなら秘密鍵を指定するが、
      // cognitoなど外部サービスが発行してるならsecretOrKeyProviderを利用。
      secretOrKeyProvider: passportJwtSecret({
        // 公開鍵をキャッシュする。これがfalseだと、毎リクエストごとに
	// 公開鍵をHTTPリクエストで取得する必要がある。
        cache: true,
        rateLimit: true,
        jwksRequestsPerMinute: 5,
        jwksUri: `${AUTHORITY}/.well-known/jwks.json`,
      }),
      // passReqToCallback: true, // これをtrueにするとvalidateの第一引数にRequestを使用できる。
    });
  }

  // JWT検証後、デコードされたpayloadを渡してくる。
  // 検証後に実行されることに注意。JWTが向こうであればそもそも実行されない。
  // validate自体はPromiseにすることも可能。
  public validate(payload: Claim): string {
    return payload.email;
  }
}

[パターン2 自分で検証] auth/strategy/jwt.strategy.manual.ts

ライブラリの力を借りずに自分でJWT検証するやり方です。

まず以下をインストールしてください。

 $ npm install --save passport-custom jwk-to-pem jsonwebtoken
 $ npm install --save-dev @types/jsonwebtoken @types/jwk-to-pem
auth/strategy/jwt.strategy.manual.ts
import {
  UnauthorizedException,
  Injectable,
  Logger,
} from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { Strategy } from 'passport-custom'; // passport-jwtでないので注意!

import { handler } from './jwt.verify';

// もしこれを利用したい場合は
// jwt.guard.tsのAuthGuard('jwt')を
// AuthGuard('manualJwt')にすること。
@Injectable()
export class ManualJwtStrategy extends PassportStrategy(Strategy, 'manualJwt') {
  private logger = new Logger(ManualJwtStrategy.name);

  constructor() {
    super();
  }

  public async validate(req: Request): Promise<string> {
    const info = await handler(req.get('Authorization'));
    if (!info.isValid) throw new UnauthorizedException(info.error);

    return info.email;
  }
}

'passport-jwt'のStrategyがやってくれていた内容を自力で頑張ります。やってることは

  1. requestのAuthenticationヘッダからトークンを取得。その時軽く文字数でバリデード。
  2. cognitoサーバーから公開キーを取得。
  3. jwtの署名があっているのか公開キーを使って検証
  4. 間違っていたらエラー内容を返す。
  5. あっていればデコードした内容を返す。

です。どこかの記事のコードを参考にしたのですが、参考元を忘れてしまいました。

あとこれ書いたあとにこんなライブラリを見つけてしまった。。
https://github.com/awslabs/aws-jwt-verify

auth/strategy/jwt.verify.ts
/* eslint-disable camelcase */
import { UnauthorizedException, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import jwkToPem from 'jwk-to-pem';
import request from 'request';

const logger = new Logger('jwt.verify');

// 参考 https://github.com/awslabs/aws-support-tools/blob/master/Cognito/decode-verify-jwt/decode-verify-jwt.ts

const TOKEN_USE_ACCESS = 'access';
const TOKEN_USE_ID = 'id';

const ALLOWED_TOKEN_USES = [TOKEN_USE_ACCESS, TOKEN_USE_ID];
const ISSUER = `https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${process.env.COGNITO_USER_POOL_ID}`;

export interface ClaimVerifyRequest {
  readonly token?: string;
}

export interface ClaimVerifyResult {
  readonly email: string;
  readonly clientId: string;
  readonly isValid: boolean;
  readonly error?: any;
}

interface TokenHeader {
  kid: string;
  alg: string;
}
interface PublicKey {
  alg: string;
  e: string;
  kid: string;
  kty: 'RSA'; // サンプルはstringだったがjwtToPemの型で弾かれるため、こうした
  n: string;
  use: string;
}
interface PublicKeyMeta {
  instance: PublicKey;
  pem: string;
}

interface PublicKeys {
  keys: PublicKey[];
}

interface MapOfKidToPublicKey {
  [key: string]: PublicKeyMeta;
}

export interface Claim {
  sub: string;
  email: string;
  token_use: string;
  auth_time: number;
  iss: string;
  exp: number;
  username: string;
  client_id: string;
  'cognito:username': string;
}

/** レスポンスの例
"sub": "aaaaaaaa-bbbb-cccc-dddd-example",
"aud": "xxxxxxxxxxxxexample",
"email_verified": true,
"token_use": "id",
"auth_time": 1500009400,
"iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/your_userpool_id",
"cognito:username": "HATOSUKE",
"exp": 1500013000,
"given_name": "HATOSUKE",
"iat": 1500009400,
"email": "test@example.com"
*/

let cacheKeys: MapOfKidToPublicKey | undefined;
// One time initialisation to download the JWK keys and convert to PEM format. Returns a promise.
const getPublicKeys = async (): Promise<MapOfKidToPublicKey> => {
  if (!cacheKeys) {
    const options = {
      url: `${ISSUER}/.well-known/jwks.json`,
      json: true,
    };
    const publicKeys = await new Promise<PublicKeys>((resolve, reject) => {
      request.get(options, function (err, resp, body: PublicKeys) {
        if (err) {
          logger.debug(`Failed to download JWKS data. err: ${err}`);
          reject(new Error('Internal debug occurred downloading JWKS data.')); // don't return detailed info to the caller

          return;
        }
        if (!body || !body.keys) {
          logger.debug(
            `JWKS data is not in expected format. Response was: ${JSON.stringify(
              resp,
            )}`,
          );
          reject(new Error('Internal debug occurred downloading JWKS data.')); // don't return detailed info to the caller

          return;
        }

        resolve(body);
      });
    });

    cacheKeys = publicKeys.keys.reduce((agg, current) => {
      const pem = jwkToPem(current);
      agg[current.kid] = { instance: current, pem };

      return agg;
    }, {} as MapOfKidToPublicKey);

    return cacheKeys;
  }

  return cacheKeys;
};

// Verify the Authorization header and return a promise.
function verifyProm(keys: MapOfKidToPublicKey, token?: string): Promise<Claim> {
  return new Promise((resolve, reject) => {
    // Decode the JWT token so we can match it to a key to verify it against

    if (!token || token.length < 2) {
      reject(
        new UnauthorizedException(
          "Invalid or missing Authorization header. Expected to be in the format 'Bearer <your_JWT_token>'.",
        ),
      );

      return;
    }

    const decodedNotVerified = jwt.decode(token, {
      complete: true,
    }) as { header: TokenHeader } | null;

    if (!decodedNotVerified) {
      logger.debug('Invalid JWT token. jwt.decode() failure.');
      reject(
        new UnauthorizedException(
          'Authorization header contains an invalid JWT token.',
        ),
      ); // don't return detailed info to the caller

      return;
    }

    if (
      !decodedNotVerified.header.kid ||
      !keys[decodedNotVerified.header.kid]
    ) {
      logger.debug(
        `Invalid JWT token. Expected a known KID ${JSON.stringify(
          Object.keys(keys),
        )} but found ${decodedNotVerified.header.kid}.`,
      );
      reject(
        new UnauthorizedException(
          'Authorization header contains an invalid JWT token.',
        ),
      ); // don't return detailed info to the caller

      return;
    }

    const key = keys[decodedNotVerified.header.kid];

    // Now verify the JWT signature matches the relevant key
    jwt.verify(
      token,
      key.pem,
      {
        issuer: ISSUER,
      },
      function (err, decodedAndVerified: any) {
        if (err) {
          logger.debug(`Invalid JWT token. jwt.verify() failed: ${err}.`);
          if (err instanceof jwt.TokenExpiredError) {
            reject(
              new UnauthorizedException(
                `Authorization header contains a JWT token that expired at ${err.expiredAt.toISOString()}.`,
              ),
            );
          } else {
            reject(
              new UnauthorizedException(
                'Authorization header contains an invalid JWT token.',
              ),
            ); // don't return detailed info to the caller
          }

          return;
        }

        // The signature matches so we know the JWT token came from our Cognito instance, now just verify the remaining claims in the token

        // Verify the token_use matches what we've been configured to allow
        if (ALLOWED_TOKEN_USES.indexOf(decodedAndVerified.token_use) === -1) {
          logger.debug(
            `Invalid JWT token. Expected token_use to be ${JSON.stringify(
              ALLOWED_TOKEN_USES,
            )} but found ${decodedAndVerified.token_use}.`,
          );
          reject(
            new UnauthorizedException(
              'Authorization header contains an invalid JWT token.',
            ),
          ); // don't return detailed info to the caller

          return;
        }

        // Verify the client id matches what we expect. Will be in either the aud or the client_id claim depending on whether it's an id or access token.
        const clientId = decodedAndVerified.aud || decodedAndVerified.client_id;
        if (clientId !== process.env.COGNITO_CLIENT_ID) {
          logger.debug(
            `Invalid JWT token. Expected client id to be ${process.env.COGNITO_CLIENT_ID} but found ${clientId}.`,
          );
          reject(
            new UnauthorizedException(
              'Authorization header contains an invalid JWT token.',
            ),
          ); // don't return detailed info to the caller

          return;
        }

        // Done - all JWT token claims can now be trusted
        resolve(decodedAndVerified as Claim);
      },
    );
  });
}

// Verify the Authorization header and call the next middleware handler if appropriate
function verifyMiddleWare(
  pemsDownloadProm: Promise<MapOfKidToPublicKey>,
  token?: string,
) {
  return pemsDownloadProm
    .then((keys) => {
      return verifyProm(keys, token);
    })
    .then((decoded: Claim) => {
      // Caller is authorised - copy some useful attributes into the req object for later use
      logger.debug(`Valid JWT token. Decoded: ${JSON.stringify(decoded)}.`);

      return {
        email: decoded.email,
        clientId: decoded.client_id,
        isValid: true,
      };
    })
    .catch((err: any) => {
      logger.debug(String(err));

      return { email: '', clientId: '', error: err, isValid: false };
    });
}

// Get the middleware function that will verify the incoming request
const handler = async (auth?: string): Promise<ClaimVerifyResult> => {
  // Fetch the JWKS data used to verify the signature of incoming JWT tokens

  // Check the format of the auth header string and break out the JWT token part
  if (!auth || auth.length < 10) {
    throw new UnauthorizedException(
      "Invalid or missing Authorization header. Expected to be in the format 'Bearer <your_JWT_token>'.",
    );
  }

  const authPrefix = auth.substring(0, 7).toLowerCase();
  if (authPrefix !== 'bearer ') {
    throw new UnauthorizedException(
      "Authorization header is expected to be in the format 'Bearer <your_JWT_token>'.",
    );
  }

  const token = auth.substring(7);

  const pemsDownloadProm = getPublicKeys().catch((err) => {
    // Failed to get the JWKS data - all subsequent auth requests will fail
    logger.debug(err);

    return { err };
  });

  return verifyMiddleWare(pemsDownloadProm, token);
};

export { handler };

Discussion