Next.js(Client)とNestJS(API)でNextAuthの認証を使ってみた
JWT関連のRFC (by ChatGPT4)
- RFC 7519 - JSON Web Token (JWT): JWTの基本概念、データ構造、エンコーディング手順を定義しています。これはJWTの基本的な仕様を提供するドキュメントです。
- RFC 7520 - Examples of Protecting Content Using JSON Object Signing and Encryption (JOSE): JSON Web Signature (JWS)とJSON Web Encryption (JWE)を使用して、JWTと他のJOSEオブジェクトを保護する方法に関する例を示しています。
- RFC 7515 - JSON Web Signature (JWS): JWTの署名部分を担当するJSON Web Signature (JWS)の仕様を定義しています。
- RFC 7516 - JSON Web Encryption (JWE): JWTの暗号化部分を担当するJSON Web Encryption (JWE)の仕様を定義しています。
- RFC 7517 - JSON Web Key (JWK): JSON形式で表現される暗号鍵の仕様を定義しています。JWKは、JWTの署名と暗号化に使用される鍵を表現するために使用されます。
- 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リポジトリ
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