はじめに
研究データ管理基盤「GakuNin RDM」と Next.js アプリケーションを OAuth2 で連携する方法を解説します。GakuNin RDM は OSF(Open Science Framework)互換の API を提供しているため、OSF の OAuth2 フローを参考に実装できます。
本記事では、next-auth を使用した実装方法と、アクセストークンの自動リフレッシュ というハマりポイントについて詳しく説明します。
GakuNin RDM とは
GakuNin RDM(Research Data Management)は、国立情報学研究所(NII)が提供する研究データ管理サービスです。
- URL : https://rdm.nii.ac.jp/
- API : OSF 互換 REST API(https://api.rdm.nii.ac.jp/v2/)
- 認証 : OAuth2(https://accounts.rdm.nii.ac.jp/)
研究者が研究データを安全に保存・共有・公開できるプラットフォームで、学認(GakuNin)認証との連携により、日本の大学・研究機関のユーザーが利用できます。
事前準備
1. OAuth アプリケーションの登録
GakuNin RDM の設定画面から OAuth アプリケーションを登録します。
- https://rdm.nii.ac.jp/settings/applications/ にアクセス
- 「Developer application を登録する」をクリック
- 以下を設定:
- Application name : アプリ名
- Application homepage URL :
http://localhost:3000(開発時) - Application description : 説明
- Authorization callback URL :
http://localhost:3000/api/auth/callback/gakunin
登録後、Client ID と Client 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,
};
},
},
],
};
ポイント
- token.request のカスタマイズ : GakuNin RDM は
application/x-www-form-urlencoded形式でのトークンリクエストを期待するため、カスタムリクエスト関数を実装 - 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 を連携する際のポイント:
- カスタムプロバイダー として設定が必要
- トークンリクエスト は
application/x-www-form-urlencoded形式 - アクセストークンの自動リフレッシュ を実装しないと、ログイン済みでも 401 エラーが発生する
- リフレッシュトークンも期限切れ の場合に備えて、クライアント側でエラーハンドリングを実装
特に3番目のトークンリフレッシュは見落としがちなので、OAuth2 連携を実装する際は必ず考慮しましょう。