NextAuth.jsで独自サインインページを作成する場合の注意すべきこと

NextAuth.jsには標準でもサインインページとサインアウトページは用意されているのだが、実務ではそのまま使いづらく、独自のサインインページを用意したいことが多いのではないかと思う。

本稿では、独自のサインインページを作成する場合の設定の概要と注意点を共有する。

AuthOptionsの設定

AuthOptionsのpagesにサインインページのパスを指定してやればよい。

import { AuthOptions } from "next-auth";
...

function newAuthOptions() {
  return {
    providers: [
      AutodeskProvider({
        ...
      })
    ],
    ...
    pages: {
      signIn: '/signin',
      signOut: '/signin',
      error: '/signin',
    },
  } satisfies AuthOptions;
}

逆に言えば、このサインインページのパス/signInに独自のサインインページを作っておく必要がある。

サインインページの作成

独自のサインインページを作るにあたって、サインインサインアウトの処理はnext-auth/reactsignIn, signOut関数をimportして、ボタンのonClickなどに割り当ててやればよい。

import { signIn, signOut } from "next-auth/react";
...

function SigninComponents(props: Props) {

  const params = useSearchParams();
  const error = params.get('error');
  const callbackUrl = params.get('callbackUrl') || "/";

  async function signInHandle() {
    await signIn('autodesk', { callbackUrl })
  }

signIn関数の呼び出しにあたっては、上記の例に示す通り、いくつかの引数を指定したほうが望ましい。

1番目の引数には、利用するOIDCプロバイダを指定する。OIDCプロバイダがひとつだけの場合は引数で指定することで、無駄なプロバイダ選択画面をスキップすることができる。

2番目の引数には、サインイン後にリダイレクトするURLをcallbackUrlフィールドに持たせて指定する必要がある。例えば、/sampleが認証が必要なページだとして、認証なしでアクセスすると、サインインページに飛ばされて認証を求められる。その際、認証後は当初アクセスしたかった/sampleにリダイレクトされることが親切である。 また、このcallbackUrlを指定しない場合は、サインインURLにそのまま戻ってきてしまう。今回は/signinというURLに専用のサインインページを設けるOptionを例に示したが、この例の場合は認証後は認証後にアクセスできるページにリダイレクトされてほしいのではないかと思う。その場合、指定されていない場合にリダイレクトするページを指定してやる必要がある。

このあたりのふるまいは意外とユーザの使い勝手に大きく影響するため、気を付けて設定しておきたい。設定については以上である。

NextAuth.jsのセッションからAccessTokenを取得できるようにする。

一般的な話であるが、OAuth認証の基本的な仕組みを前提として確認する。OAuth認証を利用するWebアプリは、OIDCプロバイダから提供されたアクセストークンの正しさを、ユーザプロファイル情報の取得可否によって確認する。つまり、認証の過程においてWebアプリは、OIDCプロバイダのアクセストークンを取得している。この取得したアクセストークンを保持しておいて、OIDCプロバイダの提供するAPI呼び出しに使おう考えるのはごく自然なことである。

そういうわけで、本記事ではNextAuth.jsでの認証後、アクセストークンを取得する方法について扱う。 NextAuth.jsの仕組み上、アクセストークンはセッションに保存し、利用する際もセッションデータから取得することになる。

本記事で扱ったNextAuth.jsのバージョンは4.24.5である。過去記事でも検討したAutodeskのAPSをOIDCプロバイダとして利用して検証した。

ma38su.hatenablog.com

型情報の拡張

NextAuth.jsのSession型には、アクセストークンは含まれていない。そのため、型定義を拡張する必要がある。 とりあえず、以下のファイルを作成することで必要な型の拡張ができる。 (ここの細かい仕組みはよくわかっていない。)

/app/types/next-auth.d.ts

import { DefaultSession } from "next-auth";
import { JWT } from "next-auth/jwt";

declare module "next-auth" {
  interface Session {
    accessToken?: string;
  }
}

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

認証処理の設定

callbacksに、sessionとjwtの処理を追加する。

/app/api/auth/[...nextauth]/option.ts

import { AuthOptions } from "next-auth";

...

function newAuthOptions() {
  return {
    providers: [
      AutodeskProvider({ ...  })
    ],
    callbacks: {
      async session(params) {
        const { session, token } = params;
        // JWTトークンのaccessTokenをセッションへコピーする。
        session.accessToken = token.accessToken;
        return session;
      },
      async jwt(params) {
        const { token, account } = params;
        if (account) {
          // サインイン時にアクセストークンをJWTに保存する。
          token.accessToken = account.access_token
        }
        return token
      },
    },
    ...
  } satisfies AuthOptions
}

export const options = newAuthOptions();

これで認証処理の設定はOK。アクセストークンはサインイン時にJWTに保存するので、テストする場合はサインインしなおす必要がある。

セッション情報の取得

サーバサイドでsessionを取得する場合は以下。

import { options } from "../auth/[...nextauth]/option";

export async function GET(req: Request) {
  const session = await getServerSession(options);
  ...
}

getServerSessionの引数には認証処理を設定したoptionsを渡さないとアクセストークンがセッションに含まれてこない。

クライアントサイドの場合はSessionProviderでWrapしたうえでuseSessionで取得する。 App Routerを利用している場合はuse clientが必須。

"use client"
import { SessionProvider } from "next-auth/react"
import { useSession } from "next-auth/react"

function App() {
  return <SessionProvider>
    <SessionView />
  </SessionProvider>
}

function SessionView() {
  const { data: session } = useSession()
  return <pre>{JSON.stringify(session)}</pre>
}

参考文献

公式に大体書いてある。

next-auth.js.org

Next.JS App RouterにおいてSVGをAPIから出力する方法

SVGを最近よく活用している。CanvasよりReactとの相性はよいし、ベクターなのも好み。 (Three.jsを使えると面白いけど、、その機会はなかなかない。)

だいたいの場合は、HTMLに埋め込んで使えばいいので、特別なことをする必要はない。 ただ、たまに画像として保存したり、書き出したりしたいことがある。PPT用素材として使うとか、コンテンツとして独立させて、Markdownドキュメントから読み込ませるなど。

そういったケースにも対応するため、Next.JSのApp Router環境において、SVGをサーバサイドで生成して画像として提供する方法を探していたのだが、やっと解決策が見つかった。

以下のような感じ。

/app/api/svg/route.tsx

import React from "react";

function MySvg() {
  return (
    <svg version="1.1"
        baseProfile="full"
        width="300" height="200"
        xmlns="http://www.w3.org/2000/svg">
      <rect width="100%" height="100%" fill="red" />
      <circle cx="150" cy="100" r="80" fill="green" />
      <text x="150" y="125" font-size="60" text-anchor="middle" fill="white">SVG</text>
    </svg>
  )
}

export async function GET() {
  const ReactDOMServer = (await import('react-dom/server')).default
  const body = ReactDOMServer.renderToStaticMarkup(<MySvg />);

  return new Response(
    body, {
    status: 200,
    headers: {
      "Content-Type": "image/svg+xml",
    },
  });
}

これで/api/svgへアクセスすると、MySVGの内容がブラウザに表示される。

ChartGPT 4に相談すると

import { renderToStaticMarkup } from 'react-dom/server'

を教えてくれた。助かる。

ただ、そのままimportすると、

Error: 
  × You're importing a component that imports react-dom/server. To fix it, render or return the content directly as a Server Component instead for perf and security.

セキュリティの問題なのか、エラーがでてしまった。 githubながめてたらdynamic importで対応できるようだった。

  const ReactDOMServer = (await import('react-dom/server')).default

staticなsvg生成の範囲では問題ないと思うのだが、使用する場合はリスクもしっかり検討する必要はありそうではある。

関連する試みとしては、過去にPNGAPI経由で提供するケースも検討した。 ma38su.hatenablog.com

DeepL APIの試用

APIを試してみたのでメモ。

import 'dotenv/config'

const API_KEY = process.env.DEEPL_API_KEY;
const API_DOMAIN = process.env.DEEPL_API_DOMAIN;
if (API_KEY == null || API_DOMAIN == null) {
  throw new Error();
}

const API_ENDPOINT = `https://${API_DOMAIN}/v2/translate`;

type TranslationLang = 'JA' | 'EN';

async function main() {
  const text = '翻訳したい言葉をここに書く。';
  await translate(text, 'JA', 'EN');
}

type TranslationResponse = {
  translations: { detected_source_language: TranslationLang, text: string }[]
}

async function translate(
  text: string, sourceLang: TranslationLang, targetLang: TranslationLang
): Promise<void> {

  const body = new FormData();
  body.append('text', text);
  body.append('target_lang', targetLang);
  body.append('source_lang', sourceLang);

  const res = await fetch(API_ENDPOINT, {
    method: 'POST',
    headers: {
      Authorization: `DeepL-Auth-Key ${API_KEY}`
    },
    body,
  });
  if (!res.ok) {
    throw new Error();
  }

  const { translations } = await res.json() as TranslationResponse
  const textList = translations.map(({text}) => text);
  console.log(textList)
}

ただ個人アカウントだと、DeepL API Proを契約すると、DeepL Proが契約できなくなるっぽくてちょっと困っている。

画像を返すAPIの作成

Azure Static Web Appではstaticなファイルにはmiddlewareが機能しないっぽいので、 画像にも認証をかけたい場合はAPIで画像を返すようにする必要がある。

Static Web Appってそういうものだよなーと思っているので仕方ない気がするし、 そもそも認証が必要なアプリはStatic Web App向きではないような気がしているものの、 パワープレイでなんとかしたいこともある。

ということで、非公開パスにある画像を読み込んで返すAPIを組んでみる。 以下のコードはNext.js(App Router)のもの。

import * as fs from "node:fs/promises";

export async function GET() {
  const closedPath = ...
  const content = await fs.readFile(path, { encoding: "binary" } );
  return new Response(Buffer.from(content, 'binary'), {
    status: 200,
    headers: {
      "Content-Type": "image/png",
    },
  });
}

これで認証チェックを追加すればOK。 いろんな画像フォーマットに対応するには拡張子とかでContent-Typeを切り替えればよさそう。

もう少し気になっているのはSVGを返したい場合。ちょっと勝手が違う気はするのでまた別途調べる。

Azure Static Web App ServiceにNext.js(App Router)をデプロイする。

以前にAzure Web App Serviceへのデプロイについての記事を書きました。 ma38su.hatenablog.com

今回はこのデプロイをブラッシュアップして、Azure Pipelineからstatic web appへもデプロイできるようにしました。

2024/2/10 追記

Next.JSのmiddlewareうまく動作しない問題もります。原理はよくわかってないもののNextAuth.jsは期待通りの動作をしました。 それに加えてたデプロイがうまくいかないトラブルも起きたため、自分はWebAppServiceに戻しました。

Next.jsのビルドの設定

まず前提として、next.jsをstandaloneモードでビルドします。ビルド先のディレクトリも指定しておきます。 ビルド先を指定しない場合はこの先の処理が変わると思います。

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  distDir: 'build',
}
module.exports = nextConfig

次に、デプロイ関係のちょっとした処理を書き足します。Next.jsではstandaloneモードでビルドすると、publicディレクトリなどの配置を調整してやる必要があります。(Vercelを使う場合は気にしなくていいわけですが。) Azure PipelineからAzure Static Web Appへのビルドでは、CI/CDパイプライン中でビルドを実行しないので、package.jsonに書くことにします。 次に、package.jsonのscriptsに、の記述の例を示します。postbuildにnpm run buildの完了後に実行する処理を記述します。

{
  ...
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "postbuild": "cp -r public build/standalone && mkdir -p build/standalone/public/_next && cp -r build/static build/standalone/public/_next/",
    "start": "node build/standalone/server.js",
    "nstart": "next start",
    "lint": "next lint"
  },
  ...
}

以下は必要ないのかもしれない。(Azure static appへのデプロイでよしなに対応されているから?)

cp -r build/static build/standalone/public/_next/

ビルドのための設定は以上です。

Azure Pipelineの定義

以下でOK。

name: Azure Static Web Apps CI/CD

trigger:
  - main

jobs:
- job: build_and_deploy_job
  displayName: Build and Deploy Job
  pool:
    vmImage: ubuntu-latest
  steps:
  - checkout: self
  - task: AzureStaticWebApp@0
    inputs:
      production_branch: 'main'
      azure_static_web_apps_api_token: $(AZURE_STATIC_WEB_APPS_API_TOKEN)
      app_location: "/"
      output_location: 'build'

これだけです。 プリセットのタスクAzureStaticWebApp@0がよしなにやってくれます。 $(AZURE_STATIC_WEB_APPS_API_TOKEN)でお気づきかと思いますが、VariablesにAzure Static Web App Serviceで発行したTokenを設定する必要はあります。

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>

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