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) で安全にした。
交換日記アプリ kokan-nikki に OAuth ログインを丸一日かけて入れた。Google / LINE / X の 3 プロバイダ + email-based 自動 link + ユーザ操作後の merge 確認まで。実装中に「これは事前に整理しておけば早かった」と思った設計判断をいくつか残しておく。
出発点
Auth.js v5 (Resend Email Provider) + Postgres + Prisma で、メール認証は既に動いていた。今日やったのは、
- Google / LINE / X の OAuth を足す
- 既存メールユーザと OAuth ユーザを同一人物として扱う
- 別の 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
HttpOnlySecureSameSite=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 画面に飛ぶことになる。
防御は二重に:
signIncallback で、cookie のtoUserIdと現セッションのuserIdを突き合わせる。違ったら cookie を削除して result?status=mismatchconfirmLinkMergeaction (確認ボタン押下時の 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」を頭に入れた状態で始めたい。