I think I had some success so far. I was able to hit the right endpoint, found here after all. Below is the code, however I still have to implement the token refresh logic. I will update the code when I'm done. At this point I have another question though:
In order to enhance NextAuth token with required Hasura custom claims, I need to set a new iat and exp value. These values are passed to NextAuth by FusionAuth in the account.accessToken property on the jwt callback event. Is there a way and is it safe to decode and grab all values from account.accessToken? It looks like it contains exactly all props I need, together with exact iat and exp.
// [...nextauth].js api route
import {cleanEnv, host, str} from 'envalid'
import jwt from 'jsonwebtoken'
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
const env = cleanEnv(process.env, {
  FUSIONAUTH_DOMAIN: host(),
  FUSIONAUTH_CLIENT_ID: str(),
  FUSIONAUTH_SECRET: str(),
  FUSIONAUTH_TENANT_ID: str(),
  SECRET: str(),
})
const FUSIONAUTH_REFRESH_TOKEN_URL = `${FUSIONAUTH_DOMAIN}/oauth2/token?`
async function refreshAccessToken(token) {
  try {
    const url =
      FUSIONAUTH_REFRESH_TOKEN_URL +
      new URLSearchParams({
        client_id: env.FUSIONAUTH_CLIENT_ID,
        client_secret: env.FUSIONAUTH_SECRET,
        tenant_id: env.FUSIONAUTH_TENANT_ID,
        grant_type: "refresh_token",
        refresh_token: token.refreshToken,
      });
    const response = await fetch(url, {
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
      method: "POST",
    });
    const refreshedTokens = await response.json();
    if (!response.ok) {
      throw refreshedTokens;
    }
    return {
      ...token,
      accessToken: refreshedTokens.access_token,
      accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
      refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, // Fall back to old refresh token
    };
  } catch (error) {
    console.log(error);
    return {
      ...token,
      error: "RefreshAccessTokenError",
    };
  }
}
export default NextAuth({
  providers: [
    Providers.FusionAuth({
      id: 'fusionauth',
      name: 'FusionAuth',
      domain: env.FUSIONAUTH_DOMAIN,
      tenantId: env.FUSIONAUTH_TENANT_ID,
      clientId: env.FUSIONAUTH_CLIENT_ID,
      clientSecret: env.FUSIONAUTH_SECRET,
      scope: 'offline_access',
    }),
  ],
  secret: env.SECRET,
  session: {jwt: true},
  jwt: {
    secret: env.SECRET,
    async encode({secret, token}) {
      const jwtClaims = {
        ...token,
        iat: Date.now() / 1000,
        exp: Math.floor(Date.now() / 1000) + 60 * 30,
        'https://hasura.io/jwt/claims': {
          'x-hasura-allowed-roles': token.roles,
          'x-hasura-default-role': 'user',
          'x-hasura-role': 'user',
          'x-hasura-user-id': token.sub,
        },
      }
      return jwt.sign(jwtClaims, secret, {algorithm: 'RS512'})
    },
    async decode({secret, token}) {
      return jwt.verify(token, secret, {algorithms: ['RS512']})
    },
  },
  pages: {},
  callbacks: {
    async jwt(token, user, account, profile) {
      if (user && profile) {
        token.id = user.id
        token.roles = profile.roles
      }
      return token
    },
    async session(session, token) {
      if (token) {
        const encodedToken = jwt.sign(token, env.SECRET, {algorithm: 'RS512'})
        session.id = token.id
        session.token = encodedToken
        session.error = token.error
      }
      return session
    },
  },
  events: {},
})