はじめに

研究データ管理基盤「GakuNin RDM」と Next.js アプリケーションを OAuth2 で連携する方法を解説します。GakuNin RDM は OSF(Open Science Framework)互換の API を提供しているため、OSF の OAuth2 フローを参考に実装できます。

本記事では、next-auth を使用した実装方法と、アクセストークンの自動リフレッシュ というハマりポイントについて詳しく説明します。

GakuNin RDM とは

GakuNin RDM(Research Data Management)は、国立情報学研究所(NII)が提供する研究データ管理サービスです。

研究者が研究データを安全に保存・共有・公開できるプラットフォームで、学認(GakuNin)認証との連携により、日本の大学・研究機関のユーザーが利用できます。

事前準備

1. OAuth アプリケーションの登録

GakuNin RDM の設定画面から OAuth アプリケーションを登録します。

  1. https://rdm.nii.ac.jp/settings/applications/ にアクセス
  2. 「Developer application を登録する」をクリック
  3. 以下を設定:
    • Application name : アプリ名
    • Application homepage URL : http://localhost:3000(開発時)
    • Application description : 説明
    • Authorization callback URL : http://localhost:3000/api/auth/callback/gakunin

登録後、Client IDClient Secret が発行されます。

2. 環境変数の設定

# .env.local
GAKUNIN_CLIENT_ID=your_client_id
GAKUNIN_CLIENT_SECRET=your_client_secret
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your_random_secret
OSF_SCOPE=osf.full_read osf.full_write

next-auth でのカスタムプロバイダー設定

GakuNin RDM は next-auth のビルトインプロバイダーには含まれていないため、カスタムプロバイダーとして設定します。

基本設定

// lib/auth.ts
import type { NextAuthOptions } from "next-auth";

export const authOptions: NextAuthOptions = {
  providers: [
    {
      id: "gakunin",
      name: "GakuNin RDM",
      type: "oauth",
      clientId: process.env.GAKUNIN_CLIENT_ID,
      clientSecret: process.env.GAKUNIN_CLIENT_SECRET,
      authorization: {
        url: "https://accounts.rdm.nii.ac.jp/oauth2/authorize",
        params: {
          client_id: process.env.GAKUNIN_CLIENT_ID,
          scope: process.env.OSF_SCOPE || "osf.full_read osf.full_write",
          response_type: "code",
          redirect_uri: `${process.env.NEXTAUTH_URL}/api/auth/callback/gakunin`,
        },
      },
      token: {
        url: "https://accounts.rdm.nii.ac.jp/oauth2/token",
        async request(context) {
          const body = new URLSearchParams({
            client_id: process.env.GAKUNIN_CLIENT_ID!,
            client_secret: process.env.GAKUNIN_CLIENT_SECRET!,
            code: context.params.code as string,
            grant_type: "authorization_code",
            redirect_uri: `${process.env.NEXTAUTH_URL}/api/auth/callback/gakunin`,
          });

          const res = await fetch(
            "https://accounts.rdm.nii.ac.jp/oauth2/token",
            {
              method: "POST",
              headers: {
                "Content-Type": "application/x-www-form-urlencoded",
              },
              body,
            }
          );

          const json = await res.json();
          if (!res.ok) {
            throw new Error(`Token request failed: ${res.statusText}`);
          }

          return { tokens: json };
        },
      },
      userinfo: "https://api.rdm.nii.ac.jp/v2/users/me/",
      profile(profile) {
        return {
          id: profile.data.id,
          name: profile.data.attributes.full_name,
          email: profile.data.attributes.email,
        };
      },
    },
  ],
};

ポイント

  1. token.request のカスタマイズ : GakuNin RDM は application/x-www-form-urlencoded 形式でのトークンリクエストを期待するため、カスタムリクエスト関数を実装
  2. userinfo のパース : OSF API は { data: { id, attributes: { ... } } } という構造でレスポンスを返すため、profile 関数でマッピング

ハマりポイント:トークンの自動リフレッシュ

問題

OAuth2 のアクセストークンには有効期限があります(GakuNin RDM では約1時間)。

next-auth のデフォルト実装では、トークンのリフレッシュは行われません 。そのため、ログイン後しばらくすると、以下のようなエラーが発生します:

Failed to fetch projects: 401 - {"errors":[{"detail":"User provided an invalid OAuth2 access token"}]}

ユーザーはログイン済みなのに、API が 401 エラーを返す、という混乱を招く状況になります。

解決策

JWT コールバックでトークンの有効期限を管理し、期限切れ前に自動リフレッシュを行います。

// lib/auth.ts

// 型定義の拡張
declare module "next-auth" {
  interface Session {
    accessToken?: string;
    error?: string;
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    accessToken?: string;
    refreshToken?: string;
    accessTokenExpires?: number;
    error?: string;
  }
}

// リフレッシュ関数
async function refreshAccessToken(token: JWT) {
  try {
    const body = new URLSearchParams({
      client_id: process.env.GAKUNIN_CLIENT_ID!,
      client_secret: process.env.GAKUNIN_CLIENT_SECRET!,
      refresh_token: token.refreshToken!,
      grant_type: "refresh_token",
    });

    const response = await fetch(
      "https://accounts.rdm.nii.ac.jp/oauth2/token",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body,
      }
    );

    const refreshedTokens = await response.json();

    if (!response.ok) {
      throw new Error("RefreshAccessTokenError");
    }

    return {
      ...token,
      accessToken: refreshedTokens.access_token,
      accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
      refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
    };
  } catch (error) {
    return {
      ...token,
      error: "RefreshAccessTokenError",
    };
  }
}

// authOptions に callbacks を追加
export const authOptions: NextAuthOptions = {
  // ... providers 設定 ...

  callbacks: {
    async jwt({ token, account, user }) {
      // 初回サインイン時
      if (account) {
        return {
          ...token,
          accessToken: account.access_token,
          refreshToken: account.refresh_token,
          accessTokenExpires: Date.now() + (account.expires_in as number) * 1000,
          userId: user?.id,
        };
      }

      // トークンがまだ有効な場合(60秒のバッファ付き)
      if (token.accessTokenExpires && Date.now() < token.accessTokenExpires - 60000) {
        return token;
      }

      // トークンが期限切れ → リフレッシュ
      return refreshAccessToken(token);
    },

    async session({ session, token }) {
      session.accessToken = token.accessToken;
      session.error = token.error;
      return session;
    },
  },
};

フロー図

┌─────────────────────────────────────────────────────────────┐
│                      JWT Callback                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  account 存在? ──Yes──> 初回サインイン                      │
│       │                  - accessToken 保存                 │
│       No                 - refreshToken 保存                │
│       │                  - accessTokenExpires 保存          │
│       ▼                                                     │
│  accessTokenExpires > now + 60s?                           │
│       │                                                     │
│      Yes ──────────────> token をそのまま返す               │
│       │                                                     │
│      No                                                     │
│       │                                                     │
│       ▼                                                     │
│  refreshAccessToken() ──> 新しい token を返す               │
│                           または error をセット             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

クライアント側でのエラーハンドリング

リフレッシュトークンも期限切れの場合、session.error"RefreshAccessTokenError" がセットされます。これを検知して再サインインを促すことができます。

// components/SessionGuard.tsx
"use client";

import { useSession, signIn } from "next-auth/react";
import { useEffect } from "react";

export function SessionGuard({ children }: { children: React.ReactNode }) {
  const { data: session } = useSession();

  useEffect(() => {
    if (session?.error === "RefreshAccessTokenError") {
      // トークンリフレッシュに失敗  再サインインを促す
      signIn("gakunin");
    }
  }, [session]);

  return <>{children}</>;
}

API での使用例

// app/api/rdm/projects/route.ts
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";

export async function GET() {
  const session = await getServerSession(authOptions);

  if (!session?.accessToken) {
    return NextResponse.json(
      { error: "Not authenticated" },
      { status: 401 }
    );
  }

  // 自動リフレッシュ済みの accessToken を使用
  const response = await fetch("https://api.rdm.nii.ac.jp/v2/users/me/nodes/", {
    headers: {
      Authorization: `Bearer ${session.accessToken}`,
    },
  });

  const data = await response.json();
  return NextResponse.json(data);
}

まとめ

GakuNin RDM と next-auth を連携する際のポイント:

  1. カスタムプロバイダー として設定が必要
  2. トークンリクエストapplication/x-www-form-urlencoded 形式
  3. アクセストークンの自動リフレッシュ を実装しないと、ログイン済みでも 401 エラーが発生する
  4. リフレッシュトークンも期限切れ の場合に備えて、クライアント側でエラーハンドリングを実装

特に3番目のトークンリフレッシュは見落としがちなので、OAuth2 連携を実装する際は必ず考慮しましょう。

参考