NextAuth.jsでAutodeskアカウントでセッション管理するカスタムプロバイダーを作ってみた。

NextJsの認証を簡単に実現するモジュールのひとつであるNextAuth.jsについて、Autodeskアカウントを使ってみようということで、 今回はカスタムプロバイダーとしてAutodesk Platform ServicesのOAuthを試してみたので、その結果を残しておきます。

いまauth.jsへのリブランディング中なのと、nextjs自体でのPage RouterからApp Routerへの移行が重なっているからか、だいぶドキュメントはグダグダな状態でしたが、 既存のプロバイダーの定義をみながらやってみたらあっさりカスタムプロバイダーも動作しました。 (細部まで設定を詰めるといろいろあるのかもしれませんが。)

公式のAPIリファレンスはこちら。 APIs | Autodesk Platform Services

カスタムプロバイダーの作成

まず、カスタムプロバイダーの定義から。 以下を適当な場所、例えば/auth/providers/autodesk.tsというファイルを作ってPrividerを定義します。

import { OAuthConfig } from "next-auth/providers/oauth"

type AutodeskProfile = {
  sub: string,
  name: string,
  given_name: string,
  family_name: string,
  preferred_username: string,
  email: string,
  email_verified: boolean,
  profile: string,
  picture: string,
  locale: "en-US" | string,
  updated_at: number,
}

export default function Autodesk(
  {
    clientId,
    clientSecret,
    logo,
  }: {
    clientId: string,
    clientSecret: string,
    logo: string,
  }
): OAuthConfig<AutodeskProfile> {

  return {
    id: "autodesk",
    name: "Autodesk",
    type: "oauth",
    version: '2.0',
    checks: [],
    authorization: {
      url: "https://developer.api.autodesk.com/authentication/v2/authorize",
      params: {
        response_type: 'code',
        scope: "data:read",
      },
    },
    token: 'https://developer.api.autodesk.com/authentication/v2/token',
    userinfo: "https://api.userprofile.autodesk.com/userinfo",
    profile(profile) {
      const { sub: id, name, email, picture: image } = profile;
      return { id, name, email, image }
    },
    style: {
      logo,
      bg: "#000",
      text: "#fff",
    },
    options: {
      clientId,
      clientSecret,
    },
  }
}

AutodeskProfileは、APSのuser infoのAPIから取得できるユーザ情報に則って定義しています。 これをNextAuth.jsで求められるUser型に変換して使うわけです。 logoにはロゴの画像を準備してその画像のURLを渡します。 versionは指定しなくても動作しました。

カスタムプロバイダーの設定(App Router)

次に、作成したProviderを設定していきます。 app/api/auth/[...nextauth]/route.tsを作成し、以下とすればOKです。 あとは環境変数としてAPS_CLIENT_ID、APS_CLIENT_SECRETなどをよしなに定義する必要はあります。 (開発環境であれば.env.localなどに定義しておきます。)

import NextAuth from "next-auth";
import AutodeskProvider from "@/auth/providers/autodesk";

const handler = NextAuth({
  providers: [
    AutodeskProvider({
      clientId: process.env.APS_CLIENT_ID!,
      clientSecret: process.env.APS_CLIENT_SECRET!,
    })
  ],
});

export {
  handler as GET, handler as POST,
};

認証の適用

とりあえず丸ごと認証をかけてみます。

export { default } from "next-auth/middleware"

ページでもAPIでも認証されてなければ、/api/auth/signinにリダイレクトされるようになります。

ただ、また認証前の画面で表示するロゴは認証されてなくても表示する必要があります。 APIもリダイレクトではなく、401などのコードを返すべきではあります。

認証の対象外にするページやリソースAPIがある場合は以下のようにconfigのmatcherで定義できます。

export const config = {
  matcher: ["/((?!adsk-logo.svg|api).*)"], // ?!で否定です。
};

next-auth.js.org

API

認証の適用からAPIは除外した場合には、API個別に認証状態を判定させる必要があります。

サーバサイドでは、getServerSessionが使えます。

import { getServerSession } from "next-auth";
import { AuthOptions } from "./auth/[...nextauth]/route";

export async function GET() {
  const session = await getServerSession(AuthOptions);
  if (!session) {
    return new Response('401 Unauthorized', { status: 401 });
  }
  return Response.json({user: session.user});
}

ページ

フロントエンドの各ページでセッション情報を使うにはuseSessionというhookが用意されています。

'use client'
import React from "react";
import { signIn, signOut, useSession } from "next-auth/react";

function AuthPage() {
  const { data, status } = useSession();

  switch (status) {
    case 'loading':
      return <div>Loading....</div>;
    case 'authenticated':
      if (data != null) {
        const { name, email, image } = data.user!;
        return (<>
          <h2>Authorized</h2>
          <ul>
            <li>Name: {name}</li>
            <li>Email: {email}</li>
            <li>{ image && <img src={image} alt='profile' width={40} height={40} />}</li>
          </ul>
  
          <button onClick={() => signOut()}>
            Sign Out
          </button>
        </>)
      }
    default:
      return <div>
        <h2>Unauthorized</h2>

        <button onClick={() => signIn()}>
          Sign In
        </button>
      </div>
  }
}
export { AuthPage }

useSessionはクライアントでのみ動作するため、'use client'が必要です。 middlewareで認証状態をチェックするページだとすると、statusがunauthenticatedになることはないはずなのでそのあたりのコードは不要ではあります。

middlewareで認証をチェックしないとしても、useSessionのhookはページ自体のロードよりも数秒遅れるため、セッションデータはundefinedになることがあります。 この時間差が気になるので、これはRSCというかSSRというかサーバ側で解消することにします。

RSCに書き換え

サーバサイドで処理するため、'use client'を消して、useSessionをgetServerSessionに変更し、関数にasyncをつけます。 それから、Sign InボタンやSign Outボタンの関数は、クライアントコンポーネントでしか動作しないため、コンポーネントを切り出してそれぞれ'use client'を付けます。

import React, { ReactNode } from "react";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
import { SignInButton } from "./SignInButton";
import { SignOutButton } from "./SignOutButton";

async function AuthPage() {
  const session = await getServerSession(AuthOptions);

  if (!session) {
    return <div>
      <h2>Unauthenticated</h2>
      <SignInButton />
    </div>
  }

  const { name, email, image } = session.user!;
  return (<>
    <h2>Authenticated</h2>
    <ul>
      <li>Name: {name}</li>
      <li>Email: {email}</li>
      <li><img src={image} alt='profile' width={40} height={40} /></li>
    </ul>

    <SignOutButton />
  </>)
}

export { AuthPage }

SignOut ボタンだけ例を示しておくと、こんな感じです。

'use client'

import { signOut } from "next-auth/react"

function SignOutButton() {
  return <button onClick={() => signOut()}>
    Sign Out
  </button>
}

export { SignOutButton }

これでページのロードと同時にセッション情報が表示されるようになりました。 コードもすっきりしたかと。

本番環境へのデプロイ

nextjsデフォルトのlocalhost:3000で開発しているうちは気づかないが、クラウドへのデプロイするとうまく動作しなくなって困りました。 ドキュメントを読むと、デプロイするサーバ上のURLを定義する必要があるとのこと。 (試行錯誤して一晩溶かしました。)

NEXTAUTH_URL

Provider選択画面のスキップ方法

Autodeskアカウントを対象とするようなケースでは、一般向けのアプリというよりは特定企業向けの用途となることが多いとおもいます、 そのような場合はProviderも複数用意しないので、ユーザにProviderを指定させる画面もスキップしたいことがあるかと思います。

調べてみると、組み込みのSignIn関数にProvider名を渡せばよいことがわかりました。

<button onClick={() => signIn('autodesk')}>Sign In</button>

とりあえずこんなところで。気が向いたら補足します。