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/edo1z/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;
		}
	}
}
Rust🦀, Network⚡, PostgreSQL🐘, Unity🎮

Tags

rust  (9)
rocket  (7)
svelte  (5)
c++  (4)
vscode  (3)
sqlx  (3)
glfw  (2)
opengl  (2)
nestjs  (2)
render  (2)
wsl2  (2)
goerli  (1)
geth  (1)
nft  (1)
gui  (1)
tetris  (1)
jwt  (1)
prisma  (1)
urql  (1)
mdsvex  (1)
tmux  (1)
nvim  (1)
axum  (1)
vim  (1)
pacman  (1)
Cursor  (1)
VSCode  (1)
PHP  (1)