Introduction

This article explains how to integrate the research data management platform “GakuNin RDM” with a Next.js application using OAuth2. Since GakuNin RDM provides an API compatible with OSF (Open Science Framework), implementation can be based on the OSF OAuth2 flow.

This article provides a detailed explanation of the implementation using next-auth and the pitfall of automatic access token refresh.

What is GakuNin RDM?

GakuNin RDM (Research Data Management) is a research data management service provided by the National Institute of Informatics (NII).

It is a platform where researchers can safely store, share, and publish research data, and through integration with GakuNin authentication, users from Japanese universities and research institutions can use it.

Preparation

1. Registering an OAuth Application

Register an OAuth application from the GakuNin RDM settings screen.

  1. Access https://rdm.nii.ac.jp/settings/applications/
  2. Click “Register a developer application”
  3. Configure the following:
    • Application name: App name
    • Application homepage URL: http://localhost:3000 (for development)
    • Application description: Description
    • Authorization callback URL: http://localhost:3000/api/auth/callback/gakunin

After registration, a Client ID and Client Secret are issued.

2. Setting Environment Variables

# .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

Custom Provider Configuration with next-auth

Since GakuNin RDM is not included in next-auth’s built-in providers, it needs to be configured as a custom provider.

Basic Configuration

// 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,
        };
      },
    },
  ],
};

Key Points

  1. Customizing token.request: Since GakuNin RDM expects token requests in application/x-www-form-urlencoded format, a custom request function is implemented
  2. Parsing userinfo: Since the OSF API returns responses in { data: { id, attributes: { ... } } } structure, mapping is done in the profile function

Pitfall: Automatic Token Refresh

Problem

OAuth2 access tokens have an expiration time (approximately 1 hour for GakuNin RDM).

In the default implementation of next-auth, token refresh is not performed. Therefore, after being logged in for a while, the following error occurs:

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

This creates a confusing situation where the user is logged in but the API returns a 401 error.

Solution

Manage the token expiration in the JWT callback and automatically refresh before expiration.

// lib/auth.ts

// Type definition extensions
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;
  }
}

// Refresh function
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",
    };
  }
}

// Add callbacks to authOptions
export const authOptions: NextAuthOptions = {
  // ... providers configuration ...

  callbacks: {
    async jwt({ token, account, user }) {
      // On initial sign-in
      if (account) {
        return {
          ...token,
          accessToken: account.access_token,
          refreshToken: account.refresh_token,
          accessTokenExpires: Date.now() + (account.expires_in as number) * 1000,
          userId: user?.id,
        };
      }

      // If token is still valid (with 60-second buffer)
      if (token.accessTokenExpires && Date.now() < token.accessTokenExpires - 60000) {
        return token;
      }

      // Token expired -> refresh
      return refreshAccessToken(token);
    },

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

Flow Diagram

┌─────────────────────────────────────────────────────────────┐
│                      JWT Callback                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  account exists? ──Yes──> Initial sign-in                   │
│       │                   - Save accessToken                │
│       No                  - Save refreshToken               │
│       │                   - Save accessTokenExpires          │
│       ▼                                                     │
│  accessTokenExpires > now + 60s?                            │
│       │                                                     │
│      Yes ──────────────> Return token as-is                 │
│       │                                                     │
│      No                                                     │
│       │                                                     │
│       ▼                                                     │
│  refreshAccessToken() ──> Return new token                  │
│                           or set error                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Client-Side Error Handling

If the refresh token has also expired, session.error is set to "RefreshAccessTokenError". This can be detected to prompt re-sign-in.

// 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") {
      // Token refresh failed -> prompt re-sign-in
      signIn("gakunin");
    }
  }, [session]);

  return <>{children}</>;
}

API Usage Example

// 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 }
    );
  }

  // Use auto-refreshed 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);
}

Summary

Key points when integrating GakuNin RDM with next-auth:

  1. Configuration as a custom provider is required
  2. Token requests must be in application/x-www-form-urlencoded format
  3. Without implementing automatic access token refresh, 401 errors will occur even when logged in
  4. Implement client-side error handling for cases when the refresh token also expires

The third point, token refresh, is easy to overlook, so be sure to consider it when implementing OAuth2 integration.

References