認証基盤のマルチアカウントとアカウント選択 (prompt=select_account)
認証基盤 (uzu auth platform) の拡張仕様。1 つのブラウザに複数の UZU アカウントの SSO セッションを保持し、呼び出し側クライアントが prompt=select_account を付けたときだけアカウント選択画面を表示できるようにする。
ステータス
段階 1 (Firebase 情報のみで表示) を実装中 (2026-08)。本ドキュメントが実装の仕様書を兼ねる。
背景・目的
現状の SSO セッションは auth_session cookie にセッション ID を 1 つだけ持つ「1 ブラウザ = 1 アカウント」構成。別アカウントでログインすると cookie が上書きされ、元のアカウントに戻るには再ログインが必要になる。
本拡張で実現すること:
- 複数アカウントのセッション保持 — ブラウザに複数の SSO セッションを併存させる
- アカウント選択画面 — 呼び出し側が
prompt=select_accountを付けたときだけ、保持中のアカウント一覧から選んでログインできる。無条件に選択画面を挟んだり、特定クライアントだけ除外したりはしない (挙動の決定権は呼び出し側パラメータに置く) - アカウントの識別表示 — 選択画面に表示名・メールアドレス等を表示し、アカウントを識別できるようにする。表示情報は段階導入とし、まず Firebase Auth の情報のみで出す (トップページ
/と同じ情報)
prompt パラメータは OIDC の同名パラメータと同じ名前・意味論に乗せる。将来 prompt=login (強制再認証) を足すときも自然に拡張できる。
設計原則: 依存方向は auth ← 各サービス
apps/auth は uzu (backend) / studio / mdms.jp の各サービスに問い合わせを行わない。アカウント表示に各サービスのプロフィール (ニックネーム・アイコン) を使いたくなっても、auth → サービスの照会は追加せず、段階 2 のとおり各サービス側から auth へ push する形で集約する。
セッション・Cookie 設計
変更後の構成
| Cookie | 値 | 役割 |
|---|---|---|
auth_session | セッション ID × 1 | アクティブセッション (現行と同一の意味)。prompt なしの自動リダイレクトはこれを使う |
auth_session_list (新設) | セッション ID をハイフン区切りで連結 | ブラウザが保持する全セッションのリスト。先頭が最近使った順 |
- 属性は両 cookie とも現行
auth_sessionと同じ (HttpOnly / SameSite=Lax / Secure /Domain=AUTH_DOMAIN/ Max-Age 400 日) - Redis 側のセッションデータ構造 (
auth:session:<id>→{ uid, sign_in_provider, ... }) は変更しない - 保持上限は 5 アカウント。超過時は末尾 (最も使っていない) セッションを Redis ごと削除して追い出す
- セッション ID は 64 hex 文字なので、5 件で cookie 値は約 330B。サイズ制限 (4KB) に対して十分小さい
不変条件と整合ルール
- 新コードの書き込みでは常に「
auth_sessionの ID =auth_session_listの先頭」を維持する - 読み取り時に 2 つの cookie が食い違っていた場合 (ローリングデプロイ中に旧 Pod が
auth_sessionだけ書き換えたケース) はauth_sessionを正としてアクティブ扱いし、リストに無ければ先頭に追加して修復する auth_session_listがありauth_sessionが無い場合はリスト先頭をアクティブに昇格する- リスト内の各 ID は使用時に Redis で検証し、失効していたらリストから除去する (自己修復)
uid の重複排除
ログイン成功時 (POST /api/callback)、リスト内の既存セッションを Redis で引き、同じ uid のセッションがあれば新セッションで置き換える (旧セッションは Redis から削除)。「同じアカウントが選択肢に 2 回出る」状態を作らない。リスト上限が 5 なので、この走査は最大 5 回の Redis GET で済む。
ロールアウト安全性
auth_sessionの名前・値形式・意味を一切変えないため、既存セッションの移行は不要。旧 Pod はauth_session_listを知らないだけで正常動作する- DB スキーマ変更なし、Redis データ構造変更なし。破壊的変更の段階分割は不要で 1 リリースで出せる
prompt パラメータ仕様
GET /login に任意パラメータ prompt を追加する。
| 値 | 挙動 |
|---|---|
| (なし) | 現行どおり。アクティブセッションが有効なら自動リダイレクト |
select_account | 自動リダイレクトをスキップし、アカウント選択画面を表示 |
| その他の値 | 無視して現行どおり (first-party 中心のため、厳密な OIDC のようにエラーにはしない) |
prompt=select_account 時のフロー分岐:
自動リダイレクト (prompt なし) のアカウント決定
「最後に明示的に使ったアカウント」= auth_session が指すアクティブセッションを使う。
- 新規ログイン (
/api/callback) とアカウント選択 (/api/select-account) のたびにアクティブを更新する。Google アカウントの「デフォルトアカウント」と同じメンタルモデルで、「直前に studio で使ったアカウントのまま studio_editor に入れる」という SSO 本来の期待と一致する - Redis の
last_accessed_at(最終アクセス順) は採用しない。バックグラウンドのセッション検証でも更新されてしまい、ユーザーから見て予測できないため - アクティブセッションが失効していて他のセッションが残っている場合は、黙って別アカウントへ切り替えず選択画面を出す。意図しないアカウントでログインさせる事故の方が、1 タップ増えるコストより痛い
| アクティブの状態 | 他の有効セッション | 挙動 |
|---|---|---|
| 有効 | — | アクティブで自動リダイレクト (現行どおり) |
| 失効 | 0 件 | ログインフォーム (現行どおり) |
| 失効 | 1 件以上 | アカウント選択画面を表示 (自動では選ばない) |
アカウント選択画面
表示要素
各アカウント行に表示するもの。情報源は Firebase Auth の UserRecord とセッションのみで、各サービスへの照会は行わない (前述の設計原則)。トップページ / のアカウント表示と同じ情報になる。
| 要素 | ソース | フォールバック |
|---|---|---|
| アイコン | 主表示名のイニシャルのプレースホルダー | — |
| 主表示名 | Firebase displayName | email → phoneNumber |
| 副表示 1 | email / phoneNumber (主表示名と重複しない場合) | 非表示 |
| 副表示 2 | ログイン方式 (セッションの sign_in_provider) | 非表示 (旧セッション等で不明な場合) |
メールアドレスを表示するのは、Firebase 情報だけではメール/電話番号登録ユーザーの displayName が空のことが多く、メール無しでは複数アカウントを区別できないため (Google のアカウント選択画面と同じ構成)。選択画面は自ブラウザのセッション保有者にしか表示されない。段階 2 でニックネーム・アイコンが入った後、メール表示を続けるかは再判断する。
画面イメージ
┌──────────────────────────────┐
│ [サービスアイコン] │
│ UZU STUDIO にログイン │
│ │
│ アカウントを選択 │
│ ┌──────────────────────────┐ │
│ │ (へ) へんてこ │ │
│ │ hen@example.com │ │
│ │ ログイン方法: Google │ │
│ ├──────────────────────────┤ │
│ │ (サ) サブのなまえ │ │
│ │ sub@example.com │ │
│ │ ログイン方法: Apple │ │
│ └──────────────────────────┘ │
│ + 別のアカウントでログイン │
└──────────────────────────────┘- アカウントをタップしたら確認ダイアログを挟まず即リダイレクトする
- 「別のアカウントでログイン」で通常のログインフォームへ。ログイン完了でリストに追加され、そのアカウントで即リダイレクト
- 選択画面にログアウト等の削除系操作は置かない。セッション管理はトップページ
/に集約する (v1 をシンプルに保つ) - サービス名・アイコンのヘッダー (
client_idベース) は現行ログインフォームと同じものを使う - i18n は現行どおり 5 言語 (ja / en / ko / zh-CN / zh-TW) 対応
トップページ / の拡張
現状の単一アカウント表示を複数アカウント一覧に拡張する。
- リスト内の全アカウントを表示 (表示情報は選択画面と同じ)
- アカウント個別の「ログアウト」と「すべてログアウト」を置く
- サポートが「UZU アカウントをログアウトしたい」ユーザーに案内する唯一の URL、という役割は維持する
表示情報の段階計画
各サービスに散っているユーザープロフィール (uzu の users.nickname / icon_path、mdms.jp の User.name / iconUrl) をどう表示に使うかは、依存方向の原則 (auth ← 各サービス) を守るため 2 段階に分ける。
段階 1 (本ドキュメントの実装範囲): Firebase 情報のみ
Firebase Auth の UserRecord (displayName / email / phoneNumber) とセッションの sign_in_provider だけで表示する。
- auth から各サービスへの照会を持たない (依存ゼロ・外部障害の影響なし)
- 取得は
lib/account-profile.tsのgetAccountProfileに集約されており、UI・API はこの関数が返すAccountProfile型しか見ない。段階 2 への移行はこの関数の情報源差し替えだけで済む
段階 2 (別ワークストリーム): auth 所有の共通プロファイルへ push 集約
auth の Postgres に account_profile (firebase_uid PK / display_name / icon_url / source / updated_at) を持ち、各サービス側から プロフィール作成・更新時に auth の内部 API へ push する。
- auth に
account_profileテーブルとプロフィール受け口の内部 API を追加 (service-to-service 認証が必要) - 各サービスの push: backend の
createUser/updateUserProfile、mdms_jp のupsertUserAuth/update_userの 4 接点。auth 障害でサービス側の更新を失敗させないよう非同期にする - 既存ユーザーのバックフィル (一回きりのバッチ。優先順位: uzu nickname → mdms.jp name → Firebase displayName)
getAccountProfileをaccount_profile参照に差し替え
リリース順序は「受け口 → push → バックフィル → 差し替え」を守る (バックフィル前に差し替えると大半のユーザーで表示が Firebase フォールバックに落ちる)。mdms_jp の merge_users / override_firebase_uid (firebase_uid の付け替え運用) への追従も設計に含めること。
過去に却下した案
「auth が backend / mdms_jp の GraphQL を都度照会する」実装は、依存方向が auth → サービスになるため採用しない (2026-08 決定)。mdms.jp のみで登録したユーザー (約 53%) は backend にプロフィールが無く、照会先を増やす方向に進むと依存が際限なく広がるため。
API・エンドポイント変更
| エンドポイント | 変更 | 内容 |
|---|---|---|
GET /login | 拡張 | prompt=select_account の分岐追加。選択画面のレンダリング (RSC で cookie からセッションリストを読み、プロフィールを取得して表示) |
POST /api/callback | 拡張 | セッション作成時に auth_session_list へ先頭追加 (+ uid 重複排除 + 上限超過の追い出し)。auth_session の発行は現行どおり |
POST /api/select-account | 新設 | 選択画面からの選択を受け、リダイレクト URL を返す (下記) |
GET /api/logout | 変更 | リスト内の全セッションを削除し、両 cookie をクリアする (「すべてログアウト」)。サポート案内用途の期待と一致させる |
POST /api/logout/account | 新設 | { uid } を受け、該当セッションだけリストから除去 + Redis 削除。アクティブを消した場合はリスト先頭を昇格。トップページの個別ログアウトが使う |
POST /api/select-account
リクエスト: { uid, redirect_uri, client_id?, code_challenge?, code_challenge_method?, state? }
処理:
redirect_uri/ PKCE / DCR / loopback の検証を/loginと完全に同一のガードで行う (検証ロジックは共通化する)auth_session_listの各セッションを Redis で引き、指定 uid のセッションがリストに存在することを検証 (無ければ 400)/loginの自動リダイレクトと同じ分岐で発行:code_challengeあり → 認可コード / なし → custom token- 選択されたセッションをアクティブに昇格 (
auth_session書き換え + リスト先頭へ移動) { redirect_url }を返す (クライアント側 JS が遷移)
セッション ID は HttpOnly cookie のためクライアント JS から見えない。選択は uid で指定し、サーバ側で「その uid が自分のブラウザのセッションリストに属するか」を必ず検証する。CSRF は既存 API と同様に SameSite=Lax + JSON POST で防ぐ。
シーケンス図
クライアント側 (呼び出し側) の使い方
各サービスの「アカウント切り替え」導線から、通常のログイン URL に prompt=select_account を付けて遷移するだけ:
https://auth.uzu-app.com/login?redirect_uri=<...>&client_id=studio&prompt=select_account@uzupj/authにはpromptを透過するオプションを追加する (例:useAuthRedirectLogoutと対になるswitchAccountヘルパー、または redirect URL ビルダーへのprompt引数)- パラメータを付けない既存クライアントの挙動は一切変わらない
mobile クライアントの扱い
mobile もアカウント選択を使う。ログイン導線 (新規登録導線を除く) では /login に prompt=select_account を付けて開き、in-app browser (iOS: ASWebAuthenticationSession / Android: Auth Tab) の共有 cookie jar にセッションを保存してマルチアカウント切替に使う。
- SSO cookie の発行は
prompt=select_accountを送ったログインに限る (auth-platform の PKCE フロー にあるCLIENTS_WITHOUT_SESSIONの prompt ゲート)。prompt を送らない旧アプリバージョンは従来どおり cookie を発行せず、「端末ログアウト後にブラウザ SSO で黙って再ログインされる」事故を従来挙動のまま防ぐ。prompt を送る新バージョンは SSO 自動リダイレクトの代わりに常に選択画面が出るのでこの事故は起きない。旧バージョンがほぼ消えたらゲートは削除できる - このゲートはキルスイッチを兼ねる。新アプリ配布後に問題が出ても、auth 側でゲートを戻せば cookie 保存が止まり、選択肢 0 件 → ログインフォームへのフォールスルーで新アプリはそのまま動き続ける
- in-app browser の cookie jar はシステムブラウザと共有のため、Safari / Chrome で Web 利用時のセッションがあれば選択肢として表示される (便利に働く)。選択時のアクティブ切替は client を問わず行う — ユーザーの明示操作であり、ブラウザ側 SSO の「最後に使ったアカウント」を更新するのは自然なため
- アプリ内ログアウトではブラウザ側セッションは消えない (仕様として許容)。Web クライアントのログアウトが
/api/logoutで auth 側のセッションまで削除するのとは異なる挙動で、次回ログイン時の選択画面に前のアカウントが残り続ける。アプリはブラウザの cookie を直接消せず、消すにはログアウトのたびに in-app browser で/api/logoutを開く必要がある (iOS では毎回確認ダイアログが出る・全セッション削除のため Safari 側の Web セッションも巻き込む) ため採らない。選択からのログインは必ずアカウント選択という明示操作を挟むので黙って再ログインされる事故にはならず (Google 等のアカウントピッカーと同じモデル)、完全に消す導線は auth トップページ/のログアウトのみ - 新規登録導線も
prompt=select_accountを付けて開く(新規登録で作ったアカウントもセッション保存してピッカーに入れるため)。ただし/loginはinitial_view=signUpを prompt と SSO 自動リダイレクトより優先して新規登録 UI へ直行するので、新しいアカウントを作りたい人に既存アカウントの選択画面は挟まらない。この優先は prompt の有無に依らないため、「新規登録タップなのに既存セッションで黙ってログインされる」事故は旧アプリバージョンでも起きない
セキュリティ考慮
- 選択画面もセッション保有者にしか出ない: 表示内容 (メールアドレス含む) は自ブラウザの HttpOnly cookie に紐づくセッションのプロフィールのみ。他人の情報は原理的に出ない
- redirect_uri 検証の一貫性:
/api/select-accountは/login//api/callbackと同一の検証 (DCR exact match / PKCE 必須化 / stg・prd loopback 制限) を通す。検証の実装は共通関数へ抽出し、経路ごとの緩みを作らない - プロフィール取得の uid: uid はサーバサイドが自ブラウザのセッションから取り出したものだけを使う。クライアント入力の uid でプロフィールを引く経路は作らない
実装の当たり所
| 変更 | 場所 |
|---|---|
| セッションリスト cookie の読み書き・整合ルール・uid 重複排除・上限 | apps/auth/src/lib/session.ts 拡張 |
| ログイン時のリスト追記 | apps/auth/src/app/api/callback/route.ts |
prompt 分岐 + 選択画面 | apps/auth/src/app/login/page.tsx + 選択画面コンポーネント新設 |
| アカウント選択 API | 新規 apps/auth/src/app/api/select-account/route.ts (検証ロジックは /login / callback と共通化) |
| 表示用プロフィール取得 (Firebase のみ) | apps/auth/src/lib/account-profile.ts (段階 2 でこの関数の情報源だけ差し替える) |
| 複数アカウント表示・個別/全ログアウト | apps/auth/src/app/page.tsx / components/account-status.tsx / api/logout |
prompt 透過オプション | packages/auth (@uzupj/auth) |
関連リソース
- 認証基盤 (uzu auth platform) — SSO 全体のアーキテクチャ
javascript/apps/auth/README.md— apps/auth の API 詳細