APIを公開するとき、多くは認証したユーザにだけとか、あるロールを持ったユーザにだけ公開したい場合があると思う。NestJSでは認証を実現する手段として、Node.jsでよく使われるPassportを使う仕組みが用意されている。
ただ、ドキュメントのAuthenticationを読んでも、イマイチ何をしたら良いのか分からなかった。
そこで、コードを読み解いて仕組みを理解した上で、サンプルコードを読んでみる。
サンプルコード
HTTP Bearer(トークン)から、認証されているユーザかどうかを調べるサービスHttpStrategy
を、Passportのpassport-http-bearer
を使って実現している。これはいったいどういう仕組みなのだろう?
import { Strategy } from 'passport-http-bearer';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
@Injectable()
export class HttpStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super();
}
async validate(token: string) {
const user = await this.authService.validateUser(token);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
PassportStrategy( Strategy )関数を調べる
HttpStrategy
サービスは、PassportStrategy( Strategy )
クラスを継承している。PassportStrategy
関数のGitHubソースを読んでみると、この関数はnpmで手に入るPassportの各種Strategy
クラスを引数にとり、派生クラスMixinStrategy
を作っている。
派生クラスMixinStrategy
では、元々のStrategy
に対して以下のような拡張をしている。
-
validate
抽象関数を追加 - コンストラクタ追加
- 親クラス(
Strategy
)をインスタンス化 - 与えられた引数(
any[]
型)を親クラスの第1引数に与える - コールバック関数を第2引数に与える
-
passport.use()
で自身を登録する
import * as passport from 'passport';
import { Type } from '../interfaces';
export function PassportStrategy<T extends Type<any> = any>(
Strategy: T,
name?: string | undefined
): {
new (...args): T;
} {
abstract class MixinStrategy extends Strategy {
abstract validate(...args: any[]): any; // [1]
// [2]
constructor(...args: any[]) {
const callback = async (...params: any[]) => {
const done = params[params.length - 1];
try {
done(null, await this.validate(...params));
} catch (err) {
done(err, null);
}
};
// [3-1],[3-2]
super(...args, (...params: any[]) => callback(...params));
// [4]
if (name) {
passport.use(name, this as any);
} else {
passport.use(this as any);
}
}
}
return MixinStrategy;
}
ここまででひとまず、NestJSがPassportにストラテジを登録してくれる仕組みができていることは理解できた。あとはその中身を調べてみる。
コールバック関数
Strategy
クラスのコンストラクタの第2引数であるコールバック関数は以下を行っている。なぜこんなことをするかは、コールバック関数の正体を調べるとわかる。
- 与えられたパラメータ配列
params
を展開した上でvalidate
抽象関数に渡す - 結果を
done
関数(paramsの最後の要素)の第2引数に与える
const callback = async (...params: any[]) => {
const done = params[params.length - 1];
try {
done(null, await this.validate(...params));
} catch (err) {
done(err, null);
}
};
super(...args, (...params: any[]) => callback(...params));
HTTP Bearerのコールバック関数
PassportのStrategyクラスのソース(先ほどのHTTP Bearer)を読むと、以下のような説明が書かれている。
* Creates an instance of `Strategy`.
*
* The HTTP Bearer authentication strategy authenticates requests based on
* a bearer token contained in the `Authorization` header field, `access_token`
* body parameter, or `access_token` query parameter.
*
* Applications must supply a `verify` callback, for which the function
* signature is:
*
* function(token, done) { ... }
*
* `token` is the bearer token provided as a credential. The verify callback
* is responsible for finding the user who posesses the token, and invoking
* `done` with the following arguments:
*
* done(err, user, info);
*
コールバック関数は、Strategyのverify callback
であり、最後の引数がdone
関数、その手前までがパラメータ(この場合はtoken
だけ)となっている。
その動きは以下のようにすることが求められている。
- 引数(パラメータ)を元にユーザがいるか調べる
- 有効なユーザがいるなら
done
関数の第2引数にユーザを入れてコールする - 無効なユーザなら、第1引数にエラーを、第2引数に
false
を入れてコールする - 第3引数は追加情報なので自由
PassportStrategyを使うには
これまで調べたことをまとめると以下のようになる。
- PassportStrategyはnpmで手に入るPassportのStrategyをラップし、NestJSのサービスにすることで、自動的に実体が作られ、Passport.useまでできるようにしている。
- ユーザはvalidate抽象関数 = Strategyの
verify callback
内の「パラメータを元にユーザがいるか調べる」部分だけ作れば良くなっている。
// ここでPassportの仕組みで使うコールバック関数を定義する
const callback = async (...params: any[]) => {
// 最後の引数は done( err, user, info )として扱う
const done = params[params.length - 1];
try {
// 利用者が定義するvalidate抽象関数をコールする
// うまくいけば、結果=ユーザオブジェクトが第2引数に入る
done(null, await this.validate(...params));
} catch (err) {
// 失敗したら、エラーを第1引数に入れて返す
done(err, null);
}
};
super(...args, (...params: any[]) => callback(...params));
サンプルを読み直す
NestJSのPassportとの連携機能のおかげで、validate
抽象関数を定義するだけで、認証が行えるようになっていることが分かる。validate
抽象関数では、Strategyがくれるパラメータ(この場合はtoken
)を元にユーザを調べ、そのユーザを返すだけで良いことが分かる。
import { Strategy } from 'passport-http-bearer';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
@Injectable()
export class HttpStrategy extends PassportStrategy(Strategy) {
// トークンに対応するユーザを調べるサービス「AuthService」をDIする
constructor(private readonly authService: AuthService) {
super();
}
// HTTP Bearerストラテジがtokenを渡してくれる
async validate(token: string) {
// ユーザがいるか調べる
const user = await this.authService.validateUser(token);
// いなければ「認証できていない」例外を返す
// コール元のMixinStrategy側でcatchしてくれる
if (!user) {
throw new UnauthorizedException();
}
// 有効なユーザを返す
return user;
}
}
別のStrategyならどうするか?
使いたいStrategyの説明を見る必要があるが、同じ要領だとすると以下のようになると考えられる。
- 使いたいStrategyの
verify callback
のパラメータを調べる。(HTTP Bearerの場合 token, done関数だった) - 使いたいStrategyクラスを用いたPassportStrategy(Strategy)を継承したクラスを作る。
- 前記クラスの中で validate抽象関数を定義する。
- validate抽象関数の引数は、
validate callback
のdone以外を順番通りに並べたものとする。(HTTP Bearerの場合tokenだけだった) - validate抽象関数の中身を作る。やることは簡単で、引数(tokenなど)を用いて、該当するユーザがいるかを調べ、いたらそのユーザを返す。いなかったら例外を投げる。
以上がNestJSでのPassportでStrategyを使う方法になるが、実際に組み込んで試したわけではないので、今度passport-google-oauth20
で検証してみる。