昔、FirebaseAuthのログインまわりでハマったやつを「今ならどうするか」で整理し直したメモ。
結論から先に言うと、SNS連携で credential-already-in-use を踏んで、「Firebase側にだけ取り残されたアカウントをサーバー経由で削除してから連携し直す」リカバリを実装した話。ただこれはその場しのぎで、根っこは「source of truth(ユーザーの真実をどこに持つか)」という設計の問題だったな〜というところに着地した。
ちなみにこの問題、FirebaseAuthを自前のAPIサーバー(リライング・パーティ側、以下RP側)と併用してる構成で起きるやつで、Firebase(+Firestore)で完結してるなら、たぶんそもそも起きない。
何が起きたか
iOSアプリで、既存アカウント(Apple)にGoogleを linkWithCredential で紐付けようとしたら、こうなった。
FIRAuthErrorCodeCredentialAlreadyInUse
意味は「そのGoogle credential、もう別のFirebaseユーザーに紐づいてるよ」って感じ。連携しようとした先が、すでに埋まってる状態。
で、その「すでに紐づいてるFirebaseユーザー」を調べてみると、RP側のDBには存在しない。Firebase側にだけポツンと残った、中身が空っぽのAuthレコードだった。この記事では、迷子アカウント と呼ぶ。
ちなみに名前が似てて意味が違うエラーがあるので、分けておく(地味に混同しがち)。
| エラー | いつ出る | 意味 |
|---|---|---|
credential-already-in-use(今回) | linkWithCredential 時 | その credential が既に別ユーザーに紐づいてる |
account-exists-with-different-credential | signInWithCredential 時 | 同じメアドで別プロバイダのアカウントが既にある |
なぜ迷子アカウントが生まれるのか?
原因は単純で、クライアント主導のフローだから。signInWithCredential を呼んだ瞬間にFirebase側のアカウントができるので、その後のRP側登録がコケると、Firebaseにだけ残る。
sequenceDiagram
participant C as アプリ
participant F as Firebase
participant S as RPサーバー
C->>F: signInWithCredential
Note over F: この瞬間にFirebaseアカウント生成
F-->>C: idToken
C->>S: idToken を送信
Note over S: RP側ユーザー作成…の前に失敗 💥
Note over F: Firebase側にだけ残る = 迷子
採用した手段:迷子アカウントを掃除する
迷子ができた後に掃除するリカバリを、こんな感じで組んだ。
sequenceDiagram
participant C as アプリ
participant F as Firebase
participant S as 迷子削除API
C->>F: Apple中にGoogleを linkWithCredential
F-->>C: credential-already-in-use(Gは迷子Bに紐づき)
C->>F: そのGoogleで signInWithCredential
F-->>C: 迷子Bにサインイン成功(= 所有権の証明)
C->>S: BのidTokenをBearerに乗せて削除API
Note over S: idToken検証 → uid導出 → RP側を確認
S->>F: RP側にいない=迷子なら Admin SDKで削除
S-->>C: 削除完了
C->>F: Appleに入り直して Google を再link
あと、設計でこだわったのは3つ。
- 退会APIとは口を分けた:退会用Deleteは「本人+RP側も消す」処理なので、迷子掃除と混ぜると事故る。そもそも退会以外でDeleteが呼べる状態自体がリスク
- 迷子判定はサーバー(source of truth)でやる:クライアントの言い値では消さず、RP側を引いて「いないね」を確認してから消す
- 削除対象は検証済みidTokenからサーバーが導出する:手順の「Googleで実際にサインインできた」が所有権の証明になっていて、消せるのは「自分が所有権を証明できた」かつ「RP側にいない迷子」だけ。他人のアカウントを指定して消すことはできない
とはいえ代償もあって。途中でクライアントのサインイン状態がApple→Bに切り替わり、そのBを消すので、currentUser = 消えたB という状態を一瞬経由する。結果、状態の出入りがやたら多くなり、途中でコケたときのハンドリングが、クライアント実装をかなり複雑にしたのが厳しい〜。
flowchart LR
A["Apple<br/>ログイン中"] --> B["迷子Bに<br/>サインイン"]
B --> D["Bを削除<br/>currentUser=消えたB"]
D --> A2["Appleに<br/>入り直し"]
A2 --> L["Google再link"]
補足:今回のBは中身が空なので確認なしで淡々と消してOK。これが「両方にデータがある生きたアカウント同士」だとアカウントのマージ(どっちのデータを残すか)という別問題になる。今回は迷子の掃除であってマージじゃない、という区別は意識しとくと混乱しない。
他にどんな道があったか?
今回採用したのが(以下「案1」)なんだけど、せっかくなので選択肢を並べて評価してみる。
案1: クライアント主導のまま掃除する(採用)
今やってるやつ。動くし認可も担保できてる。ただし迷子が生まれる入口は塞いでないので、迷子は今後も生まれ続け、その都度このリカバリが走る。そこは割り切り。
案2: Admin SDK + カスタムトークン主導
クライアントにFirebaseのサインインを直接させず、SNSのトークンを先にサーバーへ渡し、サーバーがOKを出したときだけAdmin SDKでアカウント作成+カスタムトークン発行する方式。これなら迷子は構造的に生まれない。
ただ linkWithCredential に頼れなくなるのが重い。連携が「自DBへのINSERT」になる代わりに「同じメアドなら連携していいの?」の本人性チェックを自前で背負う。ここをミスると乗っ取りで、Firebaseが安全にやってくれてる部分をわざわざ引き取る形になって渋い。個人的にはおすすめしない。
案3: Identity Platform + Blocking Functions
beforeCreate フックで、Firebaseアカウントが保存される前にサーバーチェックを挟み、NGなら作成自体を止められる。迷子を構造的に防ぎつつ linkWithCredential はそのまま使えるのが強い。コストはIdentity Platformへのアップグレード(=課金)。大元から直すならこれが本命。
まとめ
採用した案1は正直その場しのぎで、ちゃんと大元から直すなら案3が要る(=課金)。迷子の発生が稀だったら案1のリカバリ運用で十分 じゃないかな〜という感想。
Firebaseはほぼ無料で使えて導入も楽で便利だけど、自前APIサーバーと組み合わせた瞬間に「source of truthをどこに置くか」という設計判断を急に迫られる。ここを意識せずチュートリアル通りに進めると、今回みたいに迷子アカウントの掃除に追われることになるんだよね〜。