🐢

Auth.js の OAuthAccountNotLinkedエラーはサーバーアクションでcatchできない

に公開

これは何?

  • OAuthAccountNotLinkedエラーはサーバーアクションでcatchできない
  • 上記エラーは、クライアントに解決策を促すタイプのエラーなので適切に処理したい
  • ではどこで処理するのがよいか?についてアイデアを提供する

環境

  • Next.js 15.2.3 App Router
  • Auth.js (NextAuth) 5.0.0-beta.25

※ データベースアダプタにはPrismaを使用

OAuthAccountNotLinkedエラーとは?

すでに同名のEmailでユーザーが登録されている場合にOAuthアカウントでユーザーを新規登録しようとすると、ユーザー重複とみなされエラーとなる。Auth.jsはセキュリティの関係で、登録済みユーザーにOAuthアカウントをデフォルトでリンクさせない。

詳しくは以下:
https://authjs.dev/reference/core/errors#oauthaccountnotlinked

OAuthAccountNotLinkedエラーの理想のハンドリングは?

個人的には、連携しようとしたアカウントのemailが表示され、「すでにユーザーとして登録されているからまずログインして連携してください」と案内をするのが親切だと思う。

具体的にはどこでハンドリングするか?

真っ先に思いついたのはサーバーアクションでのtry-catchだが、うまくいかなかった。
以下のコードでcatchしたeの中身を見ると、digestしか入っていない。おそらくAuth.jsのデフォルトの認証方式でPKCEを使っている関係で、PKCEの認可プロセスの初期段階の情報が入ってしまっているように見えた。

  try {
    await signIn("github", {
      redirectTo: redirectPath,
    });
  } catch (e) {
    if (e instanceof AuthError) {
      switch (e.type) {
        case "OAuthAccountNotLinked":
        // something handling code
        default:
        // something handling code
      }
    }
    throw e;
  }

実際に欲しい情報は、PKCEの認可が完了してprofile情報を取得し、ユーザーデータベースとの通信が発生したタイミングなのだが、そのタイミングではなく、もっと前の段階の情報がcatchされているような印象を受けた(詳しくは調べてない...)

ではどこでハンドリングしたか?

以下を参考にした。
https://github.com/nextauthjs/next-auth/discussions/12407

OAuthAccountNotLinkedはエラー時にクエリパラメータに付与されるため、パラメータをサーバーコンポーネントでキャッチしてトーストメッセージを出すことも検討した。実際、そのようにハンドリングすることを推奨している節がある。この方法はAuth.jsのレールに乗っている方法で、良さそうに見えた。

参考:
https://zenn.dev/tsuboi/books/3f7a3056014458/viewer/chapter4#異なるプロバイダーに対して同一のメールアドレスでのログインを弾く対処

しかしユーザーはメールアドレスを複数もつこともある。エラーコードだけのキャッチではどのメールアドレスが重複しているか不明であり、ユーザーに適切なアクションを促すには情報が不足しているように見えた。

そこで上記の記事に従い、Auth.jssignInコールバックを使うことにした。

詳細:
https://authjs.dev/reference/nextjs#signin

具体的なコード

signInコールバックでは以下のコードを実装

auth.ts
export const {
  handlers: { GET, POST },
  auth,
  signIn,
  signOut,
} = NextAuth({
  ...authConfig,
  adapter: PrismaAdapter(prisma),
  session: {
    strategy: "jwt",
  },
  providers: [
    GitHub,
  ],
  callbacks: {
    jwt({ token, user }) {
      if (user) {
        token.id = user.id;
      }
      return token;
    },
    async signIn({ account, profile }) {
      // ユーザーに回避策を促す種類のエラーを適切にハンドリングするためのコード
      return buildProviderAuthResponse({
        authType: (await getAndDeleteCookie("mysite_provider_auth_type")) ?? "",
        profileEmail: profile?.email ?? "",
        providerAccountId: account?.providerAccountId ?? "",
        provider: account?.provider ?? "",
      });
    },
  },
});
buildProviderAuthResponse.ts
export async function buildProviderAuthResponse(
  props: Props
): Promise<boolean | string> {
  // すでに同名のEmailでユーザーが存在する場合
  if (await userExistsWithoutLinkedAccount(props)) {
    await setFlash({
      type: "error",
      message: `Email: ${props.profileEmail} のアカウントが存在します。ログインして連携してください。`,
    });
    return "/login";
  }
  // ログインを試みたが連携済みのアカウントがない場合
  if (await isSigninWithoutLinkedAccount(props)) {
    await setFlash({
      type: "error",
      message: "アカウントが存在しません。",
    });
    return "/login";
  }
  // 新規登録を試みたが連携済みのアカウントがすでにある場合
  if (await isSignupWithExistingLinkedAccount(props)) {
    await setFlash({
      type: "error",
      message: "アカウントがすでに存在します。ログインしてください。",
    });
    return "/login";
  }
  // 正常系でログインリクエストの場合は、ログイン後のフラッシュメッセージをあらかじめ設定
  if (isSigninRequest(props)) {
    await setFlash({
      type: "success",
      message: "ログインしました。",
    });
  }
  // 正常系で新規登録リクエストの場合は、登録後のフラッシュメッセージをあらかじめ設定
  if (isSignupRequest(props)) {
    await setFlash({
      type: "success",
      message: "アカウントを登録しました。",
    });
  }
  return true;
}

細かいコードは省略するが、メインのロジックは上記の通り。

まとめ

  • OAuthAccountNotLinkedエラーはサーバーアクションで単純にcatchできない
  • OAuthAccountNotLinkedエラーはクエリパラメータとして表示される
  • Auth.jssignInコールバックでハンドリングすると、重複したメールアドレスをユーザーに提示し、適切な回避策を促しやすい

サーバーアクションでcatchできれば最もありがたいのだが、自分の調査不足かAuth.jsの仕様か、単純なcatchはできなさそうだった。今回はSignInコールバックを使ってハンドリングしたが、より適切なハンドリング方法があればぜひ教えてほしい。

Discussion