👁️

Auth.js(旧NextAuth.js)のJWTにアクセストークンを含めるのは安全か調べてみた

2023/11/24に公開

気になったこと

Auth.js(旧NextAuth.js)のOAuth tutorialをやってみた際に、githubのアクセストークンをフロントで扱えるようにすることができるのか気になったので調査してみました。
その際のやり方と安全性のメモです。
環境は下記です。

  • next-auth: 4.24.5
  • Next.js: 14.0.3(page router)

やりたかったこと

sessionにアクセストークンを含めて、フロントからgithub apiを叩きにいこうとしてました。
この時にアクセストークンをフロントで参照できるようにすると危険なんじゃないかと思って安全性を調べてみました。

まずgithubでOAuth認証して、sessionにアクセストークンを含めるのは下記でできました。

pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth"
import { JWT } from "next-auth/jwt"
import GithubProvider from "next-auth/providers/github"

export default NextAuth({
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID as string,
      clientSecret: process.env.GITHUB_SECRET as string,
      authorization: {
        params: {
          scope: 'read:user',
        },
      },
    }),
  ],
  callbacks: {
    async jwt({ token, account, profile }) {
      // Persist the OAuth access_token and or the user id to the token right after signin
      if (account) {
        token.accessToken = account.access_token
        token.id = account.providerAccountId
      }
      return token
    },
    async session({ session, token, user }: any) {
    // Send properties to the client, like an access_token and user id from a provider.
    session.accessToken = token.accessToken
    session.id = token.id
    return session
  }
  }
})

次に、useSession()で取得できるsessionからアクセストークンを取得して、github apiをフロントから直接リクエストしにいくのは下記でできました。
この時に、そもそもフロントでアクセストークンが参照できるようにするのは危険なんじゃないか(ex. 改ざん、盗まれるetc)と思い安全性をググってみました。

pages/hoge.tsx
import { Octokit } from '@octokit/core';
import { useSession, signIn, signOut } from 'next-auth/react';

export default function Hoge() {
  const { data: session, status } = useSession();

  const onOctokitClick = async () => {
    // sessionにアクセストークンを含めておけば、フロントからgithub apiを叩ける
    if (session !== null && 'accessToken' in session) {
      const octokit = new Octokit({
        auth: session?.accessToken,
      });
      const res = await octokit.request('GET /user', {
        headers: {
          'X-GitHub-Api-Version': '2022-11-28',
        },
      });
      console.log(res);
    }
  };

結論

安全性については下記のように公式で言及されていました。
https://authjs.dev/concepts/session-strategies#jwt
要約すると、アクセストークンをフロントで扱えるようにすることは可能で現時点では安全だが、将来的にも安全とは言えないということでした。
以下、公式の引用です。

jwtはHttpOnlyかつサーバー側にのみあるkeyでencryptされてるから仮にjwtが盗まれてもdecryptされないので安全。

Auth.js libraries can create sessions using JSON Web Tokens (JWT). This is the default session strategy for Auth.js libraries. When a user signs in, a JWT is created in a HttpOnly cookie. Making the cookie HttpOnly prevents JavaScript from accessing it client-side (document.cookie), which makes it harder for attackers to steal the value. In addition, the JWT is encrypted with a secret key only known to the server. So even if an attacker were to steal the JWT from the cookie, they would not be able to decrypt it. Combined with a short expiration time, this makes JWTs a secure way to create sessions

ただ、encryptedされたjwtトークンの内容が絶対にdecrpytされないという仮定はしない方が良いとも書いてあるのでやはりフロントに機密情報(ex. アクセストークン)を渡すのはリスク高そう。

Even if appropriately configured, information stored in an encrypted JWT should not be assumed to be impossible to decrypt at some point - e.g. due to the discovery of a defect or advances in technology. Data stored in an encrypted JSON Web Token (JWE) may be compromised at some point. The recommendation is to generate a secret with high entropy

一方で、現時点では安全だからなのか公式でもアクセストークンをsessionに含める方法を紹介してたり、実際にその方法で運用してるっていう人もいました。

代替案

試してはないですが、アクセストークンをフロントに渡すのはやはり危険な気がするので、フロントはapi routeにリクエストを投げるだけにして、バックエンド側でアクセストークンを保持しつつ、github apiを叩きにいくのが良い気がしてます🤔

下記を試したらちゃんと動いたので下記の方がセキュアなのかなと思いました。

jwt callbackでtokenにアクセストークンやgithub idを保持させるようにする。

pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth"
import GithubProvider from "next-auth/providers/github"

export default NextAuth({
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID as string,
      clientSecret: process.env.GITHUB_SECRET as string,
      authorization: {
        params: {
          scope: 'read:user', // 必要に応じてスコープを設定
        },
      },
    }),
  ],
  callbacks: {
    async jwt({ token, account, profile }) {
      // Persist the OAuth access_token and or the user id to the token right after signin
      if (account) {
        token.accessToken = account.access_token
        token.id = account.providerAccountId
      }
      return token
    },
  }
})

フロント側は直接github apiを叩くのではなく、Nextのapiを叩いてその返り値を使うだけにする。

export default function Hoge() {
  const { data: session, status } = useSession();

  const onOctokitClick = async () => {
    // アクセストークンをフロントで扱うのはやめて、apiを叩いた返り値を使うようにした
    const res = await fetch('/api/github');
    const json = await res.json();
    
    // sessionには秘密情報が入っていない
    console.log(session);
    console.log(json);
  };

Nextのapi側は保持しているjwtからアクセストークンを取り出してgithub apiを叩いて、フロントに必要なものを返す。
こうしておけばフロントで秘密情報が参照できないので、よりセキュアなのかなと思いました。

pages/api/github.ts
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<any | Err>
) {
  const session = await getServerSession(req, res, NextAuth)
  const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET })

  if (session && token) {
    // jwt callbackで、tokenの中にアクセストークンを入れたのでapi routesで使用できる
    const octokit = new Octokit({
      auth: token.accessToken,
    });

    const apiRes = await octokit.request('GET /user', {
      headers: {
        'X-GitHub-Api-Version': '2022-11-28',
      },
    });
    res.send({  github: apiRes.data }); // 返したいデータだけjsonで返す
  } else {
    res.send({
      error: "You must sign in to get github user.",
    })
  }
}

感想

認証周りはめんどくさいシビアになりがちで大変だなと思いました。
ライブラリで簡単とはいえ、IDaaSを使う方が正解だったりするんだろうか。
分かりやすいし、いざとなったら破棄することもできるのでセッションIDを使う認証で良いんじゃないかとも思う。

参考

JWTとは
ヘッダ、ペイロード、署名からなるjsonをbase64でエンコードしたもの。
https://qiita.com/knaot0/items/8427918564400968bd2b

Discussion