同一人物がApple/Googleどちらでログインしても同じアカウントにする ― Firebase純正のlink(with:)を使わなかった理由
個人開発しているMinecraftサーバー監視アプリ「MineWatch」(公式サイト、アプリの全体像はこちら)は、ログインにFirebase Authenticationを使い、Apple/Googleどちらでもサインインできます。「同じ人がApple版とGoogle版、両方でログインしても同じMineWatchアカウントとして扱いたい」というアカウント連携機能を作ったとき、Firebase純正のlink(with:)を採用せず、バックエンド独自のテーブルを新設する設計にしました。この記事は、その判断の理由と実装です。
TL;DR
- 「Apple/Googleが同じメールアドレスなら自動で束ねられる」という当初の想定は誤りだった。Appleはメールを隠すことがあり、そもそも別メールで登録しているケースもある。
- Firebase純正の
currentUser.link(with:)は、連携先が既に別のFirebaseユーザーとして存在する場合(credential-already-in-use)、生きているFirebaseユーザーを削除してから資格情報を付け替える、という破壊的・複数ステップの処理が必要になる。途中でネットワークが切れると、どちらの資格情報でもログインできない詰み状態になりうる。 - 代わりに、Firebase Authentication側の状態には一切触れず、バックエンド独自の
auth_identitiesテーブル(1 User : Nfirebase_uid)で連携を管理する設計にした。破壊的な決定は単一のDBトランザクションに閉じ込め、失敗してもロールバックだけで済む。 - コンフリクト時は「中断/まとめる/今のデータで上書き/連携元のデータで上書き」の4択をユーザーに選ばせる。
1. 「同一メールなら自動で束ねられる」という前提が崩れた
MineWatchの認証設計は当初、「同一メールのApple/GoogleアカウントはFirebase標準のアカウントリンク機能で束ねられる」ことを、Firebaseを採用する利点の1つに挙げていました。
しかし実際に必要だったのは、同一メールアドレスとは限らない連携でした。Appleはサインイン時にメールアドレスを隠す機能(プライベートリレー)を提供していますし、そもそもApple版とGoogle版で最初から別のメールアドレスを使って登録しているケースもあります。これはFirebaseの「同一メールなら自動リンク」の対象外でした。
2. Firebase純正のlink(with:)を検討して、やめた理由
FirebaseのcurrentUser.link(with:)(iOS)/ linkWithCredential(Web)自体は、メールが一致していなくても呼び出せます。問題は、連携しようとした識別子が既に別のFirebaseユーザーとして存在する場合です。このときauth/credential-already-in-useエラーになり、解決するには「生きているFirebaseユーザーを削除してから、資格情報を付け替える」という、破壊的で複数ステップのクライアント側オーケストレーションが必要になります。
この方式だと、削除と付け替えの間でネットワークが切れたり、アプリが落ちたりすると、どちらの資格情報でもログインできない詰み状態が原理的にありえます。認証というクリティカルな経路で、こういう「途中で止まると詰む」処理を組み込みたくありませんでした。
3. 採用した設計: auth_identitiesテーブルで1 User : N firebase_uid
代わりに選んだのは、Firebase Authentication側のアカウント状態には一切触れず、バックエンド独自のテーブルで連携を管理する方式です。
実際のモデル定義です。
"""ユーザーに紐づく Firebase の識別子 (auth_identities テーブル).
1 ユーザーが複数の firebase_uid を持てるようにするためのテーブル。
Apple/Google はプロバイダごとに別の uid を発行する (同一メールでも別値)
ため、「同一人物が複数プロバイダでログインしても同じ MineWatch アカウント
として扱う」アカウント連携機能 (POST /v1/me/link) を成立させるには、
User:firebase_uid の 1:1 ではなく 1:N の関係が要る。
"""
class AuthIdentity(SQLModel, table=True):
"""User に紐づく Firebase の識別子 1 件."""
__tablename__ = "auth_identities"
id: int | None = Field(default=None, primary_key=True)
# アカウント連携解除・削除で一緒に消える。
user_id: int = Field(foreign_key="users.id", ondelete="CASCADE", index=True)
# 本人特定の唯一の鍵。Apple はメールを隠すので、メールでは判定しない。
firebase_uid: str = Field(max_length=128, unique=True, index=True)
users.firebase_uid(最初に作られたときの識別子)自体は、レガシー列としてそのまま残しています。Expand-Contract方針のExpand段階で、列自体の削除は本番で安定稼働を確認してから別途行う、という判断です。本人特定はauth_identities経由に切り替え、1つのUserが複数のfirebase_uidを持てるようになりました。
4. コンフリクト時の4択と、その実装
連携フローは、まず副作用の無い確認(POST /v1/me/link/preview)から入ります。
連携先がすでに別アカウントとして存在する場合、ユーザーに4択で選ばせます。「中断」以外の3戦略は、最終的にすべて「相手のAuthIdentityを今ログイン中のユーザーへ付け替え、相手のユーザー行を削除する」という同じ形に収束します。
async def resolve_link_conflict(
session: AsyncSession,
current_user: User,
other_user: User,
strategy: LinkStrategy,
) -> None:
"""コンフリクトを解消し、other_user を current_user へ統合する.
- merge: 双方の servers/devices を current_user 側にまとめる
(重複チェックはしない。両方残す方が「復旧できない」の警告に対して
安全な倒し方のため)
- keep_current: other_user の servers/devices には触れない。
other_user 行を削除する際に ON DELETE CASCADE で一緒に消える
- keep_source: current_user の既存 servers/devices を先に削除して
から、other_user のものを付け替える
全ステップは 1 回の commit に閉じ込める (部分適用を避ける。「復旧は
できない」と警告する操作の途中状態を DB に残すのは致命的なため)。
"""
「両方残す方が安全な倒し方」というmergeの重複無視判断や、keep_sourceの削除→付け替えの順序、そしてすべての処理が1回のcommitに閉じ込められている点が、2節の「途中で止まると詰む」問題を避ける設計そのものです。DBトランザクションが失敗しても、ロールバックされるだけで中途半端な状態は残りません。
5. 細かい設計判断
いくつか、実装を読んでいて気づいた工夫があります。
- IDトークンをリクエストボディで受け取る: 通常のエンドポイントはAuthorizationヘッダの1トークンだけを見ますが、連携APIは「今ログイン中のセッション」とは別に「連携したいもう一方のプロバイダの本人確認」も同時に必要になるため、連携先のIDトークンはボディで受け取る珍しい形になっています。
-
v1/v2で処理本体を共有する:
POST /me/link/preview・POST /me/link・DELETE /me/link/{provider}の処理本体は、v1/v2両方のrouterから同じ関数を呼ぶ形で共有し、router側にはAPIRouterのデコレータ・Depends解決・docstringだけを残しています。router層でのコード重複(SonarQubeのduplicated_lines指標にも表れる)を避けるための整理です。 -
ORMの遅延ロードに頼らない:
other_user.serversのようなリレーション経由の書き方ではなく、明示的にselect()で取得してからuser_idを書き換えています。コードのコメントには「このコードベースは非同期セッションで遅延ロードを使わない既存方針のため」とあります。 -
アカウント削除は連携後の全識別子を消す: 連携後にアカウント削除すると、紐づくすべての
firebase_uidをFirebaseから削除するよう修正されています。連携を考慮する前の実装では、最初の1件しか消えていませんでした。
6. おまけ: ついでに見つけた無関係な既存バグ
この機能のPR(4,323行・36ファイル変更)には、実装とは無関係な既存バグの修正も1つ紛れ込んでいます。internal routerとv1/servers routerで、list_serversのoperationIdが重複しており、iOS側のOpenAPI型生成が壊れていたというものです。develop単体でも再現する既存不具合で、この機能を実装する過程でたまたま見つかって直されています。
まとめ
- 「同一メールなら自動で束ねられる」という当初の想定が、実際の要件(Appleのメール非表示、別メール登録)と食い違っていたことが出発点だった。
- Firebase純正のアカウントリンクは、コンフリクト解決が破壊的・複数ステップで、途中失敗による詰み状態のリスクがある。認証という失敗が許されない経路には向かないと判断した。
- 独自の
auth_identitiesテーブルで1 User : N firebase_uidを管理し、Firebase側の状態には触れず、破壊的な決定は単一のDBトランザクションに閉じ込めることで、詰み状態を原理的に排除した。 - 「復旧できない」と警告する操作は、部分適用の余地を残さない設計にする。今回は「全部1コミット」という単純な形で実現した。
個人開発の環境での構成なので、そのまま組織に持ち込む場合は、アカウント統合時のデータ所有権や監査ログの要件を先に確認してください。
JQITのエンジニアの95%以上は未経験からの採用です。
よければコーポレートサイトにも遊びに来てください。
未経験から学べます!一緒に挑戦していきましょう![]()
noteやXもやってます↓