はじめに
社内向けのAIチャットアプリを開発しており、現状はモバイルアプリ版とWeb版があり、Firebase のプロジェクトを共有しています。
このうちWeb版を社内へ広く公開するにあたり、認証を社内の統合認証(OIDC / SSO)へ移行しました。
それまではメールアドレスとパスワードで運用していたものです。
作業自体の概要としては 「Identity Platform にアップグレードして、OIDCプロバイダを登録する」 というものなのですが、関連していくつかのエラーに当たったため、そのOIDC連携の際に起こった問題と対処についてまとめます。
対象読者
- Firebase Authentication / Identity Platform に OIDC プロバイダを繋ごうとしている方
- 既存のメール認証ユーザーを SSO へ移行しようとしている方
- ブロッキング関数(
beforeUserCreated)でアカウント作成を制限している方
課題:ログインボタンを押すたびに、違うエラーが返ってきていた
移行の疎通確認は、押すたびに症状が変わるという形で進みました。
| # | 返ってきたもの | 実際の原因 |
|---|---|---|
| ① | redirect_uri_mismatch |
IdP側に Redirect URI が未登録 |
| ② | auth/user-cancelled |
IdP側でアプリにユーザーが未割り当て |
| ③ | auth/account-exists-with-different-credential |
紐付けたIDが実際の sub と違う |
| ④ | ログインは成功。しかし初期データが作られない | ← 本題 |
①②はIdP側の設定なので省きます。
ただ②だけ補足すると、auth/user-cancelled は文言が「ユーザーが許可を拒否した」なのに、IdP側がアクセスを拒否したときにも同じコードになります。
本題は③以降になります。
検証①:sub はメールアドレスではない
既存ユーザーのデータはすべて Firebase Authentication の ユーザーUID に紐付いています。
OIDCでログインすると、別の ユーザーUID の新しいアカウントが作られるため、そのままでは既存データとの紐付けがなくなる状態となります。
今回は、既存のメールアドレスとパスワードで認証していたアカウントへ、OIDCのログイン手段を後付けする方針をとりました。
Admin SDK の providerToLink を使う方法です。
await getAuth().updateUser(uid, {
providerToLink: {
providerId: 'oidc.example',
// IdP側の一意なユーザーID。事前に分からないのでメールアドレスを入れた
uid: email,
email,
},
});
uid にはIdPが返す sub を入れる必要があります。
それが事前に分からなかったため、メールアドレスで代用したのですが、これが誤りでした。
実際のログインで返ってきたのは auth/account-exists-with-different-credential です。
Firebase から見ると「同じメールアドレスを持つ別アカウント」なので、既存アカウントに繋げられず、新規作成もできずに弾かれます。
エラーの中身を見る方法
sub の実物はエラーオブジェクトの中に入っています。
注意点として、Error オブジェクトを console.error にそのまま渡してもスタックトレースしか展開表示されません。
独自プロパティが見えないため、JSONにして明示的に出す必要があります。
const customData = err?.customData ?? {};
const tokenResponse = customData._tokenResponse ?? {};
// ⚠️ トークン類は除いてから出す
const { oauthIdToken, oauthAccessToken, idToken, pendingToken, ...safe } = tokenResponse;
console.log('[調査] _tokenResponse =', JSON.stringify(safe, null, 2));
下記のような出力が得られました。
{
"federatedId": "oidc.example/12345678",
"email": "user-a@example.com",
"emailVerified": false,
"localId": "YKzgVp...",
"needConfirmation": true,
"rawUserInfo": "{\"sub\":\"12345678\",\"preferred_username\":\"user-a@example.com\",\"email\":\"user-a@example.com\"}"
}
sub は 8桁の数値で、IdP内部のユーザーIDであり、メールアドレスではありませんでした。
そして localId に既存アカウントのUIDが入っています。
Firebase は「誰か」を正しく特定できていて、繋ぎ方だけが分からない状態でした。
_tokenResponse には oauthIdToken / oauthAccessToken / pendingToken が含まれます。
期限付きとはいえ本人になりすませる値のため、中身を確認したいときは、除外してから出力するよう注意が必要です。
原因:email_verified が無いと、メールアドレスが本体に載らない
また、先ほどの出力で得られたのですが、emailVerified: false になっています。
利用したIdPは email_verified クレームを返さない実装でした。
email_verified は OIDC の必須クレームではないので、IdP側に問題はありません。
ただ、email_verified が無いと Firebase 側の挙動が変わり、プロバイダのメールアドレスをアカウント本体へ載せなくなります。
| 参照先 | メールアドレス |
|---|---|
Firebase Auth の user.email
|
undefined |
user.providerData[0].email |
user-a@example.com |
Firestore の users/{uid}.email
|
null |
3つ目は、フロントエンドが firebaseUser.email をそのまま書いていたためです。
// 初回ログイン時にプロフィールを作る処理
const initialProfile = {
uid: firebaseUser.uid,
email: firebaseUser.email, // ← OIDC では null になる
displayName: firebaseUser.displayName || 'ゲスト',
// ...
};
user.email を見ていた処理が、OIDCユーザーを全部すり抜けます。
1. 初期データが作られない
初回ログイン時に、使い方を案内する固定スレッドを自動作成する仕組みを入れていて、判定は下記のようになっていました。
// モバイル版は匿名ログインを使っているため、メールが無ければ対象外とした
if (typeof email !== 'string' || email.trim() === '') {
console.log(`[AppManual] メールなしのため作成しません uid=${uid}`);
return;
}
ログには下記のように出ていました。
[AppManual] メールなしのため作成しません uid=R3jva...
Web版の本命ユーザーを、モバイル版の匿名ログインと誤認して弾いていました。
2. ドメイン制限がすり抜ける
アカウント作成を社内ドメインに限定するブロッキング関数を置いていたため、ドメイン制限がすり抜ける問題も起こりました。
export const blockNonCorporateSignUp = beforeUserCreated(
{ region: 'asia-northeast1' },
(event) => {
const email = event.data?.email?.toLowerCase();
// 匿名ログインなど、メールを持たない作成は通す
if (!email) return;
if (email.endsWith('@example.co.jp')) return;
throw new HttpsError('permission-denied', '…');
}
);
移行の検証中に、ログを見て気づきました。
[AuthBlocking] メールなしの作成を許可(匿名ログインとみなす)
OIDC経由の作成がすべて「匿名」と判定され、無条件に通っていました。
メール認証しか無い間は、event.data.email が常に入っていたため露出しませんでした。
3. 後追いスクリプトの対象が0人になる
既存ユーザーへ初期データを配るスクリプトも user.email でドメイン絞り込みをしていたため、素通しすると対象が0人になります。
解決策
1. メールアドレスの解決を1箇所に集約する
3箇所を個別に直すと4箇所目で同じことが起きるので、判定をユーティリティへ寄せました。
/** メールを持ちうる最小の形。Admin SDK の UserRecord と、ブロッキング関数の AuthUserRecord の共通部分 */
type EmailBearingRecord = {
email?: string;
providerData?: readonly { providerId: string; email?: string | null }[];
};
/** 本体に無ければプロバイダ側から拾う。見つからなければ本当にメールを持たない */
export function resolvePrimaryEmail(user: EmailBearingRecord): string | undefined {
const direct = user.email?.trim().toLowerCase();
if (direct) return direct;
for (const p of user.providerData ?? []) {
const fromProvider = p.email?.trim().toLowerCase();
if (fromProvider) return fromProvider;
}
return undefined;
}
ポイントは型を最小限にしたことです。
Admin SDK の UserRecord とブロッキング関数の AuthUserRecord は別の型ですが、必要な部分は同じなので、両方から渡せる形にしています。
2. 「誰か」の判定は、メールではなくプロバイダで行う
そもそも「メールの有無」でユーザーの種別を見分けていたのが誤りで、プロバイダを見るほうが確実でした。
export function hasOidcProvider(user: EmailBearingRecord): boolean {
return (user.providerData ?? []).some((p) => p.providerId.startsWith('oidc.'));
}
| ユーザー | providerData |
判定 |
|---|---|---|
| モバイル版の匿名ログイン | 空 | 対象外 |
| モバイル版のメール認証 | password |
対象外 |
| Web版のSSO | oidc.* |
対象 |
メールの有無は「どのアプリのユーザーか」を表しておらず、それを表しているのはプロバイダです。
3. 既存アカウントへの紐付けをやめた
sub が分かれば紐付けは可能ですが、運用を考えるととれませんでした。
sub はユーザーごとに違う数値IDなので対応表が必要になり、社員が増えるたびに手作業が発生します。
そこでWeb版は新しいアカウントで始める方針に切り替えました。
既存データを引き継がない前提が立っていたためです。
| 1人1アカウント(紐付け) | 1人2アカウント(併存) | |
|---|---|---|
sub の対応表 |
必要(新規入社ごとに更新) | 不要 |
| 既存データ | 引き継ぐ | 引き継がない |
| 新規入社時の作業 | あり | なし |
併存させるには、Identity Platform の設定を 「IDプロバイダごとに複数のアカウントを作成する」 に変更します。
既定の「同じメールアドレスでは1アカウント」のままだと、③のエラーで弾かれ続けます。
これで、モバイル版はメール認証の既存アカウント、Web版はSSOの新規アカウント、という形で同じメールアドレスが2つのアカウントに共存します。
実装:表示名の姓名が逆になる
日本語圏では実害が出るところです。
IdPの name クレームは「名 姓」の順で届きました。
given_name と family_name は別々に届くので、組み直せます。
beforeUserCreated は作成されるユーザーの displayName を書き換えられます。
function buildJapaneseDisplayName(profile: unknown): string | undefined {
if (typeof profile !== 'object' || profile === null) return undefined;
const claims = profile as Record<string, unknown>;
const familyName = typeof claims.family_name === 'string' ? claims.family_name.trim() : '';
const givenName = typeof claims.given_name === 'string' ? claims.given_name.trim() : '';
// ★両方揃っているときだけ組み直す。片方だけでは姓名の判別ができない
if (!familyName || !givenName) return undefined;
return `${familyName} ${givenName}`;
}
// 呼び出し側
const displayName = buildJapaneseDisplayName(event.additionalUserInfo?.profile);
if (displayName) return { displayName };
return;
クレームが揃っているときだけ組み直し、欠けていればIdPの値をそのまま使います。
設計原則:ID基盤が「正規化した値」を鵜呑みにしない
ここまでの躓きをまとめると下記になります。
| 見ている値 | 何を表しているか | 信用できるか |
|---|---|---|
user.email |
ID基盤が検証済みと判断したメール | ❌ IdPのクレーム次第で消える |
user.providerData[].email |
IdPが申告したメール | ⭕️ 届いていれば入っている |
user.providerData[].providerId |
どの手段で認証したか | ⭕️ 消えない |
user.email は「IdPが送ってきた値」ではなく「ID基盤が検証済みと判断した値」で、この違いがIdPを差し替えた瞬間に表面化します。
そして今回のようにemail が undefined になると、if (!email) という書き方が「匿名ユーザー」の意味に化けます。
エラーは出ず、ログにも「正常に許可しました」と出ます。
おわりに
OIDC移行そのものは、コンソールでプロバイダを登録するだけの作業です。
今回はメールアドレスとパスワードによる認証からの移行だったため、その周辺で「メールアドレスがある前提」の処理が一斉におかしくなりました。
対策として有効なのは以下の2点です。
-
IdPが返す実物を、最初に1度だけ見る
subに何が入るか、email_verifiedが届くか。これはIdPの実装次第で、推測できません。エラーレスポンスの_tokenResponseに全部写っているので、疎通確認のときに1度だけ出して確定させます -
「誰か」の判定を1箇所に集約する
user.emailを各所で直接見ていると、IdPを足したときに壊れる箇所が芋づる式に増えます。今回は3箇所で同時に壊れました。resolvePrimaryEmail()のような関数を1つ置き、そこ以外でメールを触らないと決めるだけで防げます
同じような構成で移行される方の役に立てば幸いです。