NestJSとcognitoでJWT認証を実現するサンプル
NestJSとJWT認証で調べて出てくるのは、JWTを自分たちで発行しているやつが多く、Cognitoなど外部がトークン発行してくれる系の認証サンプルが少なかったので、共有します。
公式サイトでは認証に関するページは以下のリンクです。
目標
以下のようにappコントローラーにjwt認証を追加します。このコントローラーにリクエストを送っても、有効なJWTをAuthenticationヘッダに付与しなければ、401エラーで弾かれる仕様です。
@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におけるガードがよくわからない人はこちらの公式サイトの解説を一読することをおすすめします。
ディレクトリ構成は以下のとおりです。
.
├──app.module.ts
├──app.controller.ts
└──auth
├──auth.module.ts
├──guard
│ └──jwt.guard.ts
└──strategy
└──jwt.strategy.ts
1つずつ解説していきたいと思います。
app.module.ts
ルート部分のモジュールです。今回、jwt認証のためのコードは特にありません。
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認証をつけたいと思います。
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
を利用します。
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
デコレーターと一緒に使えば無効な認証を弾くことができるようになります。
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-jwt
のStrategy
がコンストラクタで指定したsecretOrKeyProvider
をもとに、JWTをチェックしてくれます。有効なJWTだけvalidate
にデコードして渡してくれます。無効なJWTはその時点で401エラーを返します。
ただ厄介なのは、なぜ401エラーなのかの詳細なログを残してくれないので、トークンが悪いのか、他の実装がわるのかわかりません。その場合は次のパターン2の実装のやり方をすることでデバッグしやすいかもです。
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
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
がやってくれていた内容を自力で頑張ります。やってることは
- requestの
Authentication
ヘッダからトークンを取得。その時軽く文字数でバリデード。 - cognitoサーバーから公開キーを取得。
- jwtの署名があっているのか公開キーを使って検証
- 間違っていたらエラー内容を返す。
- あっていればデコードした内容を返す。
です。どこかの記事のコードを参考にしたのですが、参考元を忘れてしまいました。
あとこれ書いたあとにこんなライブラリを見つけてしまった。。
/* 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": "[email protected]"
*/
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 };
Author And Source
この問題について(NestJSとcognitoでJWT認証を実現するサンプル), 我々は、より多くの情報をここで見つけました https://zenn.dev/dove/articles/d45f18f6c50f10著者帰属:元の著者の情報は、元のURLに含まれています。著作権は原作者に属する。
Collection and Share based on the CC protocol