【苦しんで追うオニオンアーキテクチャの実装】NestJSでTwitter認証 ~ 認証情報を元にJWTを発行する

## この記事は何
フロントをNext.jsのAppRouter（AppRouter出たてあたりに作ったやつなので、v13でかつ、フォルダ構成のデファクトスタンダードすら成立してなかった中での試行錯誤だったで、今見るとその辺がもうごちゃごちゃ）、API Routesを別サーバーのAPIサーバーへの踏み台的に使ってみつつ、
APIサーバーは別でNestJSで実装し、認証認可の機構をそちらに持たせてみたかったんです。

**Twitter紐付け機能**を見越して、「Twitterでログイン出来るがあくまでフロントとの認可処理はAPIサーバーで発行したJWT」という機構にしたかったのですが、
Next.jsのNextAuthによる実装や、NestJS単体でのOAuth認証の実装は出てきても、⤴︎のような実装が当時なかなか検索にヒットしませんでした。

（結局Twitterログイン → JWTによる認可まで実装したのち、飽きちゃって他のログインの仕組みを導入出来なかったのですが。たはは）

「Twitterでログイン出来るがあくまでフロントとの認可処理はAPIサーバーで発行したJWT」の、特にAPI側の実装にフォーカスした記事となります。

### 書くこと
- NestJSについてざっくり
- NestJS de オニオンアーキテクチャ ﾐ⭐︎ を軽く
- Twitter認証の実装を詳しく
- Twitter認証 ~ JWTの発行・認可処理の実装を詳しく

### 書かないこと
- フロント側
- 「お前これ一年前に作ったシステムじゃねえか。なんで今更これやねん」への弁明
  - （ネタがなかった訳じゃないんです....！ 決して....！ この機に復習がしたかっただけ....！）

## 作ったもの
先に置いておきます。

フロント側

https://github.com/dodoitsu-life/dodoitsu-life-front

API側

https://github.com/dodoitsu-life/dodoitsu-life-web-api

## NestJSについて
あくまでアドカレ記事なので、NestJSについてもざっくりまとめておこうと思います。

NestJSは、TypeScriptベースで作られたNode.js向けのフレームワークで、サーバーサイド開発をより構造的かつ保守性の高い形で進めるためのツールです。
「Angularっぽい思想をNode.js上に持ち込んだ」みたいな感じがします。
デコレーターやDI（依存性注入）といった構造化・抽象化の機能がしっかりしています。

APIサーバーのエンドポイント周りはもちろん、ビジネスロジックの整理やテストのしやすさなどにもメリットが出てきます。
Angularがそもそもモジュール式構造でDDDLikeであるため、NestJSもDDDLikeなアーキテクチャに非常に相性が良く、中規模のアプリケーション開発では非常に強力かと思います。

## NestJS de オニオンアーキテクチャ ﾐ⭐︎

https://qiita.com/FAL-coffee/items/4bd0ae197505bfa27206

この章は上記事に書いた内容の簡易的な要約です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1869061/71af2f77-7789-a093-91ae-b1c7122907ab.png)

app以下でドメイン層、アプリケーション層、インフラストラクチャ層にディレクトリを分割し、
それぞれがドメインロジック・ビジネスロジック・外部APIアクセスや永続化、細かいアプリケーションの懸念点に対するアプローチを担当します。

全ての実装が抽象に依存しており、また実装はモジュール構造によってアプリケーションに適用されるため、実装の一切が実体に依存しません。

投稿（都々逸 = dodoitsu）が作成される際のフローを具体的に表すとこうなります。

```mermaid
sequenceDiagram
    participant M as main.ts (bootstrap)
    participant A as AppModule
    participant Dm as DodoitsuModule
    participant Dc as DodoitsuController
    participant Da as DodoitsuApplicationService
    participant Ds as DodoitsuService
    participant Dr as DodoitsuRepository

    M->>A: NestFactory.create(AppModule)
    A->>Dm: import DodoitsuModule
    Dm->>Dc: DodoitsuControllerが適用
    Dc->>Da: createDodoitsu呼び出し
    Da->>Ds: create呼び出し
    Ds->>Dr: create呼び出し (DB保存)
    Dr->>Ds: 結果返却
    Ds->>Da: 結果返却
    Da->>Dc: 結果返却
    Dc->>M: HTTPレスポンス返却
```

1. **`main.ts` の `bootstrap` 関数で `NestFactory.create(AppModule);` される**  
   アプリケーション起動時、`main.ts`の`bootstrap`関数で`NestFactory.create(AppModule)`が呼ばれ、`AppModule`を起点にアプリケーション全体が初期化されます。  
   つまり、`main.ts`がアプリケーションのエントリーポイントになっているわけですね。

2. **`AppModule (app.module.ts)` のコンストラクタで `DodoitsuModule` が import され、アプリケーションに適用される**
   `AppModule`はアプリ全体の根幹モジュールで、ここに`DodoitsuModule`がimportされています。これによって、Dodoitsuに関する機能がアプリケーション全体に組み込まれ、利用可能な状態になります。

3. **`DodoitsuModule (application/dodoitsu/dodoitsu.module.ts)` で `DodoitsuController` が指定されて適用されている**  
   `DodoitsuModule`では、`DodoitsuController`が指定されており、HTTPリクエストに対するエントリーポイントが定義されます。  
   ここでエンドポイント（`POST /dodoitsu` など）が提供されるわけですね。

4. **`DodoitsuApplicationService` の `createDodoitsu` が呼び出される**  
   `DodoitsuController`がHTTPリクエストを受けると、そのビジネスロジックを担当する`DodoitsuApplicationService`の`createDodoitsu`メソッドが呼び出されます。  
   ここで「Dodoitsuを作成する」というアプリケーション層の処理が行われます。  
   つまり、Controllerはリクエストを拾うだけで、実際のビジネスロジックはApplicationServiceに任せています。
   
5. **`DodoitsuService` (ドメインサービス) の `create` 関数が呼ばれる**  
   `DodoitsuApplicationService`の中で、ドメインロジックを担当する`DodoitsuService`の`create`メソッドが呼び出されます。  
   ここでは、Dodoitsuというドメインに沿った処理（バリデーションやドメインオブジェクト生成など）が行われることが想定されます。

6. **`DodoitsuService` の `create` 関数で `DodoitsuRepository` の抽象 `create` 関数が呼び出される**  
   ドメインサービスは自前でデータを永続化しないため、`DodoitsuRepository`という抽象化されたリポジトリを介してデータベースへの書き込みが行われます。  
   `DodoitsuService.create`で`DodoitsuRepository`の`create`メソッドが呼ばれ、実際にDodoitsuがDBに保存される流れとなります。
   
きっしょいっすね

## Twitter認証の実装を詳しく

ここからは実際に、APIサーバー側でどのようにTwitterログイン → JWT発行までのフローが実装されているかを見ていきます。
このリポジトリでは、`NestJS`で`Passport`を用いた`OAuth`戦略（`twitter.strategy.ts`）とJWT戦略（`jwt.strategy.ts`）、そして`AuthService`や`AuthController`を介して、以下の流れで認証が行われています。

### 全体の流れ

```mermaid
sequenceDiagram
    participant C as Client (Browser)
    participant Tw as Twitter
    participant AC as AuthController
    participant AS as AuthService
    participant US as UserService
    participant UR as UserRepository
    participant JS as JwtService

    C->>AC: GET /auth/twitter (リクエスト)
    AC->>AC: UseGuards(AuthGuard('twitter'))
    note right of AC: TwitterStrategyでTwitterへリダイレクト
    AC->>Tw: Twitter OAuth 認可フロー開始

    Tw->>AC: Callback (GET /auth/twitter/callback)
    AC->>AC: UseGuards(AuthGuard('twitter'))
    note right of AC: twitter.strategy.tsでreq.userにTwitter情報セット

    AC->>AS: authService.handleCallback(req)
    AS->>US: userService.findOne({twitterId})
    US->>UR: userRepository.findOne(...)
    UR->>US: Userエンティティ返却 or undefined
    note right of AS: 既存ユーザーがいなければUserエンティティ新規作成
    AS->>US: userService.create(userEntity) (必要な場合)
    US->>UR: userRepository.create(...)
    UR->>US: 新規Userエンティティ返却
    US->>AS: Userエンティティ返却

    AS->>AS: generateTokens(user)
    AS->>JS: jwtService.sign(payload)
    JS->>AS: access_token発行
    AS->>UR: user.refreshToken=uuid, userRepository.save(user)
    UR->>AS: 更新後のUserエンティティ返却
    AS->>AC: {access_token, refresh_token}

    AC->>C: フロントエンドのcallbackURLへリダイレクト (トークン付き)
    note right of C: ブラウザでtoken, refresh_tokenを受け取る

    note over C: 以後、APIアクセス時に<br>Authorization: Bearer access_tokenを付与するとJwtStrategyで検証

```

1. **`AuthController` (`src/application/auth/auth.controller.ts`) が `/auth/twitter` エンドポイントに対して`AuthGuard('twitter')` を用い、Twitterログインフローを開始。**
2. **Twitterでユーザーが認可を行うと、`twitter/callback` エンドポイントで再度`AuthController`が`AuthGuard('twitter')`を通じてリクエストを受け、`AuthService.handleCallback`が呼ばれる。**
3. **`AuthService` (`src/domain/auth/auth.service.ts`) にて、r`eq.user`に格納されたTwitterアカウント情報を基にユーザーをDBにUPSERT（すでにユーザーがいれば取得、なければ新規作成）。**
  ここで生成・取得されたユーザーエンティティはUserリポジトリ(`UserRepository`)を通してDBとやり取りされています。
4. **`AuthService.generateTokens`にて、`JWT access_token と refresh_token` を発行。**
  refresh_tokenはDB上に保存され、再認証時に利用可能。
5. **最終的に、フロントエンドコールバックURLに`access_token`と`refresh_token`を付与してリダイレクト。**
  この`access_token`を以降のAPIリクエストで`Authorization: Bearer <access_token>`形式でヘッダに付与すれば、認証済みとしてAPIにアクセス可能となる。

### `TwitterStrategy` での`Twitter OAuth`処理

```ts:src/infrastructure/auth/twitter.strategy.ts
@Injectable()
export class TwitterStrategy extends PassportStrategy(Strategy, 'twitter') {
  constructor(private configService: ConfigService) {
    super({
      consumerKey: configService.get<string>('auth.twitter.consumerKey'),
      consumerSecret: configService.get<string>('auth.twitter.consumerSecret'),
      callbackURL: configService.get<string>('auth.twitter.callbackUrl'),
    });
  }

  async validate(_, __, profile: any, done: (err: any, user: TwitterUserPayload) => void) {
    const { username, displayName, photos } = profile;
    const user = {
      twitterId: username,
      name: displayName,
      photo: photos[0].value,
    };
    done(null, user);
  }
}

```
`TwitterStrategy`は`Passport`の`Strategy`クラスを拡張し、twitterストラテジー名で登録しています。
`validate`メソッド内で、Twitterから返ってきたユーザー情報（profile）を整形し、`{ twitterId, name, photo }`形式のオブジェクトとしてdoneコールバックを呼び出します。
この時点で`req.user`にTwitterユーザー情報が紐づけられ、`AuthController`側の`Callback`ハンドラで利用できるようになります。

### `AuthController` での`Callback`処理

```ts:src/application/auth/auth.controller.ts の signInWithTwitterRedirect
@Get('twitter/callback')
@UseGuards(AuthGuard('twitter'))
async signInWithTwitterRedirect(@Req() req, @Res() res) {
  const user = await this.authService.handleCallback(req);
  const tokens = await this.authService.generateTokens(user);
  res.redirect(
    `${this.configService.get<string>('dodoitsuLifeCallbackUrl')}?token=${
      tokens.access_token
    }&refresh_token=${tokens.refresh_token}`,
  );
}

```

`@UseGuards(AuthGuard('twitter'))`が付与されているため、このエンドポイントへTwitterからリダイレクトされた際に`twitter.strategy.ts`で定義した`validate`が発火し、`req.user`にTwitterユーザー情報が注入されます。
続いて、`this.authService.handleCallback(req)`が呼ばれ、ユーザー情報をDBにUPSERTします。
最後に`this.authService.generateTokens`でJWTを発行し、フロントエンド用のコールバックURLに`access_token`と`refresh_token`をクエリパラメータとして付与し、リダイレクトしています。

### `AuthService` でのユーザーUPSERTとトークン発行

```ts:src/domain/auth/auth.service.ts
async handleCallback(req: Request & { user: TwitterUserPayload }): Promise<User> {
  const user = req.user;
  const userEntity = new User();

  userEntity.twitterId = user.twitterId;
  userEntity.name = user.name;
  userEntity.photo = user.photo.replace('_normal', '');

  const existingUser = await this.userService.findOne({
    twitterId: userEntity.twitterId,
  });

  if (existingUser) {
    return existingUser;
  }

  return await this.userService.create(userEntity);
}

```

`handleCallback`では、`req.user（TwitterStrategyが投入）`からTwitterIDでユーザーを検索し、なければ新規作成しています。この処理は`UserService`を通じて`UserRepository`に委譲しており、DDDライクなレイヤリングに基づき、`AuthService`はあくまで認証関連のユースケースロジックを担当するだけで、実際のDB書き込みはインフラ層のリポジトリ(`UserRepository`)が行っています。

```ts
async generateTokens(user: User): Promise<{ access_token: string; refresh_token: string }> {
  const payload: JwtPayload = {
    userId: user.id,
  };

  const accessToken = await this.jwtService.sign(payload, {
    secret: this.configService.get<string>('auth.jwt.secret'),
    expiresIn: this.configService.get<string>('auth.jwt.expire'),
  });

  const refreshToken = await this.generateRefreshToken(user);

  return {
    access_token: accessToken,
    refresh_token: refreshToken,
  };
}

async generateRefreshToken(user: User): Promise<string> {
  const refreshToken = uuidv4();
  user.refreshToken = refreshToken;
  await this.userRepository.save(user);

  return refreshToken;
}

```

`generateTokens`で`access_token`と`refresh_token`を発行します。
`access_token`は`jwtService.sign()`でJWTを生成、`refresh_token`はUUIDで適当な文字列を生成してDBに保存しておきます。
この`refresh_token`は後ほど`/auth/refresh-token`エンドポイントで新しい`access_token`を発行する際に使用されます。

### `JwtStrategy` でのJWT検証

```ts:src/infrastructure/auth/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(private readonly userService: UserService, private readonly configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: true,
      secretOrKey: configService.get<string>('auth.jwt.secret'),
    });
  }

  async validate(payload: JwtPayload): Promise<User> {
    const user = await this.userService.findOne({ id: payload.userId });
    if (!user) {
      throw new UnauthorizedException('User does not exist');
    }
    return user;
  }
}

```

`JwtStrategyはBearer`トークンからJWTを抽出し、シークレットキーで検証します。
`validate`メソッドでJWT内に格納された`userId`からユーザーを取得し、該当ユーザーが存在しなければ401エラーを投げます。
これにより、APIリクエスト時に`Authorization: Bearer <access_token>`ヘッダを付与していれば、自動的にこの`JwtStrategy`で検証され、`req.user`にユーザー情報が注入されます。

### `AuthController`でのトークンリフレッシュとログアウト

```ts
@Post('refresh-token')
@HttpCode(HttpStatus.OK)
async refreshToken(@Body() body: TokenRefreshDto) {
  const user = await this.authService.handleRefreshToken(body.refreshToken);
  if (!user) throw Error('User not found');
  const tokens = await this.authService.generateTokens(user);
  return tokens;
}

```

`refresh-token`エンドポイントでは、`refresh_token`を受け取り、DBからユーザーを特定、再度`generateTokens`で新しいJWTを発行します。
これにより、`access_token`が期限切れになっても、`refresh_token`が有効な限り新たな`access_token`を取得できます。

また、`logout`エンドポイントでは`refresh_token`を無効化（DBで`refreshToken`を`null`にする）することで、ログアウトを実現しています。

## まとめ

オニオンアーキテクチャの実装を人に説明することの難易度の高さが身に染みてわかる記事になりました。
多分実装読むのが一番早いと思います。

念の為再度置いておきます。

こんな僕の歌で誰かの命を救えれば幸いです。

https://github.com/dodoitsu-life/dodoitsu-life-web-api