miya8060
← back to blog

NOTE · 2026-05-04

OAuth 3 プロバイダ繋いだ日: User merge を signed cookie で安全に

kokan-nikki に Google / LINE / X の OAuth を入れた過程の設計メモ。同一メアドの自動 link と、別 OAuth account の merge を 2 段階確認 (HMAC signed cookie + 10min TTL) で安全にした。

authoauthnextprismaauthjs

交換日記アプリ kokan-nikki に OAuth ログインを丸一日かけて入れた。Google / LINE / X の 3 プロバイダ + email-based 自動 link + ユーザ操作後の merge 確認まで。実装中に「これは事前に整理しておけば早かった」と思った設計判断をいくつか残しておく。

出発点

Auth.js v5 (Resend Email Provider) + Postgres + Prisma で、メール認証は既に動いていた。今日やったのは、

  1. Google / LINE / X の OAuth を足す
  2. 既存メールユーザと OAuth ユーザを同一人物として扱う
  3. 別の OAuth account を後から既存ユーザに紐付け (linking)

の 3 段階。1 と 2 は素直、3 で詰まった。

X が email を返さないので User.email は nullable

X (Twitter) OAuth 2.0 は email scope を申請しても返ってこないケースが多い。Auth.js のデフォルト schema で User.email String @unique だと email: null のレコードが作れずサインインに失敗する。

model User {
  id            String    @id @default(cuid())
  // X (Twitter) OAuth 2.0 など email を返さない provider を受け
  // 入れるため nullable。@unique は維持 (Postgres の UNIQUE は NULL を複数
  // 許容する)
  email         String?   @unique
  ...
}

Postgres の UNIQUE 制約は NULL を複数許容するので、@unique を外す必要はない。メール持ちは依然として一意、メール無しは複数 OK という挙動になる。

同一メアド自動 link は安全、別 account は確認必須

OAuth は「メアドが一致 = 同じ人」を前提にできる場面とできない場面がある。今回の整理:

ケース挙動
LINE OAuth で返ったメアドが既存 User の email と一致自動 link (Account 行を後付け)
/settings から「X 連携」を起動、X account が未登録自動 link (User#B 作らない)
同じことをしたが X account が既に別の User#B に紐付いている2 段階確認後に User#B を merge

最後のケースが厄介で、ユーザ A が間違えてサインアップ済みの X account を持っているケースに該当する。何もしないと A の現セッション以外に B というゴミ User が残り続ける。merge するしかないが、merge は不可逆なので確認なしには走らせたくない。

純粋 DB 関数として mergeUsers を切り出す

Stage 1 として、まず confirmation UI と切り離した「純粋に DB を弄る関数」を実装した。

// lib/auth/merge-user.ts
export async function mergeUsers(
  fromUserId: string,
  toUserId: string,
  reason: string,
): Promise<UserMergeSummary> {
  return prisma.$transaction(async (tx) => {
    // Account / Session / Entry / Nudge / NotebookMember を to に移し替え
    // Account 競合は abort、NotebookMember は dedup、過去の merge log は付け替え
    // To が email を持たない場合のみ From から email を引き継ぐ
    // 最後に from User を delete
    // UserMergeLog を残す
  });
}

ポイント:

  • トランザクション内で完結。途中で落ちたら何も起きない
  • 不可逆操作の監査ログ UserMergeLog を必ず残す。移動した row 数や Account 競合の有無を JSON で。後から「いつ・なぜ・何が起きたか」が辿れる
  • Account 競合は abort: 同じ provider+providerAccountId の Account が両側にあれば merge は実行しない。データ汚染を防ぐ
  • NotebookMember は dedup: 同一 notebook の member 行が両側にあれば 1 行に集約
  • 過去の merge log は付け替え: B が過去に merge 元として履歴に残っていたら、A 側に rebase。履歴の連続性を保つ
model UserMergeLog {
  id         String   @id @default(cuid())
  fromUserId String                       // FK なし (削除済み User を指す可能性)
  toUserId   String
  summary    Json                         // 移動した row 数 / Account 競合 / provider 等
  executedAt DateTime @default(now())
  toUser     User     @relation(fields: [toUserId], references: [id], onDelete: Cascade)
}

fromUserId は FK にしない (merge で削除されるため)、toUserId のみ FK + Cascade。

テストは 7 ケース: 通常 merge / Account 競合 abort / NotebookMember dedup / email 引き継ぎ / self-merge guard / missing-user guard / 過去 merge log の付け替え。

Stage 2: 2 段階 signed cookie 確認

UI 側は Auth.js v5 の signIn callback で 3 ケースに分岐する:

// 1. X account 未登録      → 現 user に Account 行を後付け、/settings/link/result?status=linked
// 2. X account が同 user に link 済 → status=already
// 3. X account が別 User#B に link 済 → pending-confirm cookie に fromUserId を載せて
//                                       /settings/link/confirm に飛ばす
//                                       2 段階確認後に mergeUsers を実行

3 のケースで使う pending-confirm cookie が今回いちばん神経を使った部分。

設計

  • HMAC-SHA256 で署名: cookie 本体は user-controllable な値が混ざるので、改ざん検知必須
  • Zod でペイロード検証: 署名が合っても schema が合わなければ reject
  • 10 min TTL: 確認画面に到達してから放置されたケースを expire
  • HttpOnly Secure SameSite=Lax: OAuth callback (cross-site redirect) を踏むので Strict は使えない
// lib/auth/link-intent.ts
const PendingMergePayload = z.object({
  fromUserId: z.string().min(1),
  toUserId: z.string().min(1),
  provider: z.literal("x"),
  iat: z.number().int(),
});

cross-tab session swap 防御

ここが今日いちばん詰まった。

ユーザがタブ A で「X を連携」を起動した直後、別のタブ B で別アカウントにログインし直すと、OAuth callback が戻ってきた時に タブ A が想定していた User と現セッションの User が違う という状態が起こる。これを放置すると、知らないユーザの merge confirm 画面に飛ぶことになる。

防御は二重に:

  1. signIn callback で、cookie の toUserId と現セッションの userId を突き合わせる。違ったら cookie を削除して result?status=mismatch
  2. confirmLinkMerge action (確認ボタン押下時の Server Action) でも同じチェック。確認画面まで届いた cookie が、別ユーザのセッションで使われないように

「callback で弾けるなら action 側はいらないのでは」と一瞬思ったが、確認画面の URL を直接踏まれるケース (cookie 残存 + cross-tab) も塞ぐ必要がある。多重防御は冗長ではなく、各レイヤが別シナリオを担当する

テストは 13 unit (cookie helpers) + 12 integration (link-account actions)。

副産物: 利用規約 / プライバシーポリシー

X の OAuth 申請は利用規約と PP の URL を要求するので、/legal/terms/legal/privacy を MDX で書いて静的に出した。OAuth を入れる以前は「いつか書く」リストに入っていたが、外圧で書く方が早い。

学び

  • メール nullable は最初から。後から switch する migration はテスト全部書き直しになる
  • 不可逆操作は純粋関数として切り出す。UI と confirmation flow を別 PR に分けたら、Stage 1 だけ単体テストで完結できた。Stage 2 は Stage 1 を信じて assertion は flow に集中できる
  • signed cookie の検証は HMAC + schema 両方。署名だけだと tampering 防げても shape の取り違えに弱い
  • cross-tab race は OAuth flow に常にある。callback と action の両方で一貫性チェックを入れる
  • コミットを段階分け。今日の auth 関連 6 commit はそれぞれが独立して revert 可能。Stage 2 で何か壊れた時に Stage 1 まで戻れる安心感は実装中に効いた

OAuth はライブラリが面倒を見てくれる範囲が広い分、その外側 (linking / merging / confirmation) に応用問題が集中する。次に書くなら最初から「2 ケース分岐 + 不可逆操作の audit log」を頭に入れた状態で始めたい。