Logicky Blog

Logickyの開発ブログです

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

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/edo1z/nestjs-nextjs-nextauth-rest-example

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

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

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 ヘッダに付与しています。

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 を取得できます。

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 等で認証ユーザ情報を利用できます。

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;
        }
    }
}