Open5

Next15 × Cognito でtokenをリフレッシュする

ohtsukiohtsuki

モチベーションとか

  • Next歴0ヶ月のエケチェンが仕事で最近Nextを触る機会があったので、やったことや詰んだことのメモ
  • キャッチアップの思考整理
ohtsukiohtsuki

前提

  • フロントエンドとバックエンドを RESTful API により分離し、HTTP 通信を通じてデータの送受信を行う構成となっています。
  • ユーザー認証は AWS Cognito を用いて行い、認証成功時に発行される ID トークン(idToken)を用いてフロントエンドからバックエンド API へのリクエストに対する認可を行います。
  • Nextは15

やりたいこと

  • バックエンドとAPIでデータ送受信を行う際に、リクエストヘッダーにidTokenを差してるんだけども、有効期限が1時間なので、期限が切れたらリフレッシュしてリクエスト認可を通したい。

ここでやらないこと

  • Cognitoの設定と画面からログインするまで
  • 良さげなエラーハンドリング、エラー定義など
ohtsukiohtsuki

API Routes

認証周りのルーティング。
リフレッシュするのにシークレットハッシュを作成する必要があり、cryptoを使いたかったのでこの構成にしてrefresh-tokenはNodeで動かすよう明示的に宣言した。

app
├── api
│   └── auth
│       ├── [...nextauth]
│       │   ├── auth-options.ts
│       │   └── route.ts
│       └── refresh-token
│           └── route.ts
ohtsukiohtsuki

REFRESH TOKEN

refresh-token/route.ts
export const runtime = 'nodejs'; // cryptoがNodeでしか動かせないので

import crypto from 'crypto';
import {
  CognitoIdentityProvider,
  InitiateAuthCommandInput,
} from '@aws-sdk/client-cognito-identity-provider';
import { NextRequest, NextResponse } from 'next/server';

import { env } from '@/env.mjs';

function generateSecretHash(
  userName: string,
  clientId: string,
  clientSecret: string
): string {
  return crypto
    .createHmac('sha256', clientSecret)
    .update(`${userName}${clientId}`)
    .digest('base64');
}

async function refreshAccessToken({
  refreshToken,
  userName,
}: {
  refreshToken: string;
  userName: string;
}) {
  const cognito = new CognitoIdentityProvider({ region: 'ap-northeast-1' });
  const secretHash = generateSecretHash(
    userName, // Cognitoのサインイン設定によって何を渡して良いか変わりそう。今回はproviderAccountIdを渡す
    env.COGNITO_CLIENT_ID,
    env.COGNITO_CLIENT_SECRET
  );

  const params: InitiateAuthCommandInput = {
    AuthFlow: 'REFRESH_TOKEN',
    ClientId: env.COGNITO_CLIENT_ID,
    AuthParameters: {
      REFRESH_TOKEN: refreshToken,
      SECRET_HASH: secretHash,
    },
  };

  const res = await cognito.initiateAuth(params);
  const result = res.AuthenticationResult!;

  return {
    accessToken: result.AccessToken,
    idToken: result.IdToken ?? null,
    expiresIn: result.ExpiresIn,
  };
}

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();

    if (!body.refreshToken || !body.userName) {
      return NextResponse.json(
        { error: 'Missing refreshToken or userName' },
        { status: 400 }
      );
    }

    const newTokens = await refreshAccessToken({
      refreshToken: body.refreshToken,
      userName: body.userName,
    });

    return NextResponse.json(newTokens);
  } catch (err) {
    console.error('[refresh-token API] Failed to refresh:', err);
    return NextResponse.json({ error: 'Refresh failed' }, { status: 500 });
  }
}
ohtsukiohtsuki

jwtコールバック

jwtのコールバック内でidTokenの有効期限が切れていたら、refresh-tokenに定義したエンドポイントを叩きます。

[...nextauth]/auth-options.ts
export const { auth, handlers, signIn, signOut } = NextAuth({
  debug: process.env.NODE_ENV === 'development',
  secret: env.AUTH_SECRET,
  trustHost: true,
  providers: [
    CognitoProvider({
      clientId: env.COGNITO_CLIENT_ID,
      clientSecret: env.COGNITO_CLIENT_SECRET,
      issuer: env.COGNITO_ISSUER,
    }),
  ],
  callbacks: {
    async jwt({ token, account, user }) {
      if (account && user) {
        // 初回サインイン時にトークンを保存
        token.idToken = account.id_token;
        token.refreshToken = account.refresh_token;
        token.accessTokenExpires =
          Date.now() + (account.expires_at || 3600) * 1000;
        token.userName = account.providerAccountId;
        return token;
      }

      // 有効期限チェック
      if (Date.now() < (token.accessTokenExpires as number) - 60 * 1000) {
        return token;
      }

      // リフレッシュに必要な要素がない → セッション無効として扱う
      if (!token.refreshToken || !token.userName) {
        return {
          ...token,
          error: 'MissingRefreshInfo',
        };
      }

      // 有効期限切れならリフレッシュ
      try {
        const res = await fetch(`${env.AUTH_URL}/api/auth/refresh-token`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            refreshToken: token.refreshToken,
            userName: token.userName,
          }),
        });

        if (!res.ok) {
          throw new Error('Refresh failed');
        }

        const refreshed = await res.json();

        token.idToken = refreshed.idToken;
        token.accessTokenExpires = Date.now() + refreshed.expiresIn * 1000;

        console.log('[JWT refresh] Access token refreshed!!!');
        return token;
      } catch (err) {
        console.error('[JWT refresh] Failed to refresh access token', err);
        return { ...token, error: 'RefreshAccessTokenError' };
      }
    },