🐨

Next.js(Client)とNestJS(API)でNextAuthの認証を使ってみた

2023/03/20に公開

JWT関連のRFC (by ChatGPT4)

  1. RFC 7519 - JSON Web Token (JWT): JWTの基本概念、データ構造、エンコーディング手順を定義しています。これはJWTの基本的な仕様を提供するドキュメントです。
  2. RFC 7520 - Examples of Protecting Content Using JSON Object Signing and Encryption (JOSE): JSON Web Signature (JWS)とJSON Web Encryption (JWE)を使用して、JWTと他のJOSEオブジェクトを保護する方法に関する例を示しています。
  3. RFC 7515 - JSON Web Signature (JWS): JWTの署名部分を担当するJSON Web Signature (JWS)の仕様を定義しています。
  4. RFC 7516 - JSON Web Encryption (JWE): JWTの暗号化部分を担当するJSON Web Encryption (JWE)の仕様を定義しています。
  5. RFC 7517 - JSON Web Key (JWK): JSON形式で表現される暗号鍵の仕様を定義しています。JWKは、JWTの署名と暗号化に使用される鍵を表現するために使用されます。
  6. RFC 7518 - JSON Web Algorithms (JWA): JWT、JWS、JWEで使用される暗号アルゴリズムを定義しています。これには、デジタル署名や暗号化アルゴリズム、鍵管理アルゴリズムが含まれます。

JWTの仕組み概要

  • Header, Payload, Signatureの3つをドットでつなげたものがJWT。
  • Header, PayloadはJsonオブジェクトをBase64 Encodeした結果。
  • SignatureはEncode済みのHeaderとEncode済みPayloadをドットでつなげたものを、秘密鍵でデジタル署名した結果。
  • PayloadはBase64 Decodeすれば内容が確認可能。
    • ただし、Payload自体を暗号化する方式もある。
  • ただし、秘密鍵に対応する公開鍵でSignatureを検証し、Encode済みのHeaderとEncode済みPayloadをドットでつなげたものと内容が合致するか確認することで、改ざんされていないかの確認が可能。

JWTによる認証フローの概要

登場人物

  • Client ... A
  • ID Provider ... B
  • API ... C

Aは認証に必要な情報と共に、BにJWTをリクエストします。AはCookieにJWTを保存します。AはCにJWTと共に、必要な情報をリクエストします。CはJWTを検証し、認証・認可チェックを行い、問題なければAが必要とする情報をレスポンスします。

BはJWTの生成の際に秘密鍵を使って署名します。秘密鍵は、対称キーと非対称キーがあります。非対称キーは公開鍵・秘密鍵に分かれます。Bは秘密鍵を使い、Cは公開鍵で検証します。対称キーの場合、BのJWT署名もCの検証も同一のキーを使います。(漏洩時のリスクは対称キーの方が大きいです)

JWTの検証

署名の検証により、検証に使う鍵が不正であったり、JWTの内容が改ざんされていたりといったことをチェックする。また、署名の検証に問題がなければ、有効期限が切れていないかをチェックする。

その他、subにuser_idなどが入っているので、認可チェック等も合わせて行う。

注意点として、JWTは署名の方式を選択でき、非常に弱い署名だったり、署名しないといった選択が仕様として可能。よって、JWTのHeaderに記載されている署名方式に沿う形で検証しようとすると、署名の有効性チェックをスルーするような事態が起こりうる。基本的には署名方式を決めて、チェックする側も決められた方式でチェックするとよさそう。

NextAuthの仕組み概要

NextAuthでGithub認証等のOAuth連携をする場合の仕組み概要

登場人物

  • Client … A (Next.jsを想定)
  • NextAuth … N (AのNext.js内のNextAuthを想定)
  • Github … B (Github認証を使う場合を想定)
  • API … C (NestJSで作ったAPIを想定)

NextAuthでOAuth認証をする場合、Bでやるのは、ログインしてアクセストークンをもらい、それを使ってユーザ情報を取得するだけ。BでJWTを作成・発行してもらうわけではない。

Bで認証OKとなりユーザ情報をゲットしたら、その情報を元にN自体がID Providerとなり、セッショントークンを作成する。セッショントークンはDB保存する形式も、JWTにすることも可能。

NextAuthのデフォルトは、NEXTAUTH_SECRETを対称キーとして署名する形式です。Payloadもデフォルトで暗号化されます。よって、Payloadが途中で読み取られるリスクが低いですが、非対称キーと比べるとキー漏洩時のリスクが高いです。今回はデフォルトの状態でコード例を作成します。非対称キーを使う場合等は、下記の[…nextauth].ts内でjwtのencode関数とdecode関数を上書きします。(参考

サンプルコードのGithubリポジトリ

https://github.com/web3ten0/nestjs-nextjs-nextauth-rest-example

Next.js(A)とNextAuthのコード例

下記でNextAuthの各種設定をしています。Prismaアダプタ等でDB連携するとデフォルトはDBを利用したらセッション管理になるようですが、session.strategyにjwtを設定することで、JWTで管理します。

// client/src/pages/api/auth/[...nextauth].ts

import NextAuth from 'next-auth';
import type { NextAuthOptions } from 'next-auth';
import GithubProvider from 'next-auth/providers/github';
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { prisma } from '@mypj/database';

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID ?? '',
      clientSecret: process.env.GITHUB_SECRET ?? '',
    }),
  ],
  session: {
    strategy: 'jwt',
    maxAge: 60 * 60 * 24,
  },
  jwt: {
    maxAge: 60 * 3,
  },
};

export default NextAuth(authOptions);

下記は自分の最新の記事を取得するコード例です。記事一覧取得APIにリクエストする際にJWTをAuthorizationヘッダに付与しています。

// client/src/api/latest-posts.ts

import { getJwt } from '@/utils/auth/getJwt';
import { ApiError } from '@/errors/apiError';
import { NextApiRequest } from 'next';

export async function getLatestPosts(req: NextApiRequest) {
  const token = await getJwt(req);
  const baseurl = process.env.API_URL ?? '';
  const url = `${baseurl}/posts`;
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });
  if (!res.ok) {
    const error = await res.json();
    const message = error.message || res.statusText;
    throw new ApiError(res.status, message);
  }
  return await res.json();
}

Authorizationヘッダに付与するためのJWTを取得するコード例です。next-auth/jwtのgetToken関数を使っています。getToken関数の引数にはsecretも追加できます。secret未指定の場合はデフォルトでNEXTAUTH_SECRETを利用します。rawをtrueにするとencode済みの状態のJWTを取得できます。

// client/src/utils/auth/getJwt.ts

import type { NextApiRequest } from 'next';
import { getToken } from 'next-auth/jwt';
import { ApiError } from '@/errors/apiError';

export async function getJwt(req: NextApiRequest): Promise<string> {
  const token = await getToken({ req, raw: true });
  if (!token) throw new ApiError(401, 'jwt is none');
  return token;
}

NestJS(C)のコード例

NextAuthのデフォルトはPayloadも暗号化しています。next-auth/jwtのdecodeを使うと簡単に複合と署名の検証ができます。改竄や不正secretが原因で署名の検証に失敗するとエラーになります。署名の検証に成功すると有効期限のチェックもします。request.userにpayloadをセットすることで、controlle等で認証ユーザ情報を利用できます。

// api/src/auth/auth.guard.ts

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { decode } from 'next-auth/jwt';

@Injectable()
export class AuthGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const authorization = request.headers?.authorization;
    if (!authorization) return false;
    const token = authorization.split(' ')[1];
    if (!token) return false;
    const secret = process.env.NEXTAUTH_SECRET ?? '';
    if (!secret) return false;
    try {
      const decoded = await decode({ token, secret });
      if (!decoded) return false;
      request.user = decoded;
      return true;
    } catch (error) {
      console.error(error);
      return false;
    }
  }
}

Discussion