認証基盤 (uzu auth platform)
UZU 全プロダクト共通の独自認証基盤。Linear プロジェクト 認証基盤の導入 (Lead: Kenta Imai) の成果物。
ユーザーには「ウズアカウント」として見せている。Firebase Authentication 自体はまだバックエンドの ID 識別子として利用しているが、ログイン体験は apps/auth を中心とした SSO に統一している。
なぜ独自認証基盤を作っているか
旧 account.uzu-app.com + iframe / postMessage 方式 (authentication.md) や Firebase Auth を直接各アプリで初期化する方式には以下の限界があった。
- ウズアプリ Desktop (Windows / macOS) で Firebase Auth のサポートが弱く、利用できる認証手段が制約される (Windows は Google ログインのみなど)
- ウズスタジオ ↔ ウズスタジオエディター など別ドメインへ遷移すると Firebase Auth がセッションを引き継げず、再ログインが必要になる
- マダミス.jp 側のログインも別実装になっており、ウズアカウントとの体験が分断されていた
これらを「全サービス共通の SSO サーバー (apps/auth) + 共通 React パッケージ (@uzupj/auth)」で吸収する、というのが本基盤の狙い。
進捗 (2026-04 時点):
- 完了: ウズスタジオ / ウズスタジオエディター / ウズ Admin / Flutter (ログイン / 新規登録 / 外部サービス連携。旧ネイティブ実装は撤去済み)
- 進行中: マダミス.jp / Desktop アカウント管理移行
構成
| コンポーネント | 場所 | 役割 |
|---|---|---|
apps/auth | javascript/apps/auth | Next.js 16 製の中央 SSO サーバー (auth.uzu-app.com)。ログイン UI 提供 + Firebase ID Token を受け取り Custom Token / 認可コードを発行 + Redis で SSO セッション管理 |
@uzupj/auth | javascript/packages/auth | 各 React アプリが import する共通 Provider。AuthProvider / AuthContext / AuthRedirectGuard / useAuthRedirectLogout / getIdTokenInMemory を export |
| Firebase Authentication | GCP プロジェクト uzu-{dev,stg,prd} | UID 発行 + ID Token 検証の primitives。各バックエンドは引き続き verifyIdToken でリクエストを認可する |
| Redis (Memorystore) | apps/auth が利用 | SSO セッション (TTL 400 日) / 認可コード (TTL 5 分) / トランスファーコード (TTL 5 分) |
認証フロー
apps/auth は 2 系統のフローを提供する。Web ブラウザは Web SSO、モバイル / Desktop は PKCE。
Web SSO フロー (signInWithCustomToken 方式)
ブラウザで動く React アプリ (Studio / Admin / mdms.jp / uzutokyo) が使うフロー。
ポイント:
- SSO セッション は apps/auth が
auth_sessioncookie で管理。ドメインauth.uzu-app.com(prd) /dev.auth.uzu-app.com(dev) に対する HttpOnly Cookie なので各サービスからは見えない。サービスが apps/auth に redirect すると cookie が送られて自動ログインになる。 - Custom Token は apps/auth が
firebase-adminのcreateCustomToken(uid)で発行する短命トークン。URL クエリで運ぶ。 signInWithCustomTokenで各アプリの Firebase Auth にも本物のUserが入り、以降は通常通りgetIdToken()で Bearer を取れる。
PKCE フロー (モバイル / Desktop アプリ向け)
iOS / Android / macOS / Windows のネイティブアプリは custom URL scheme でリダイレクトを受ける。クエリで Custom Token を渡すと中間者にトークンを盗まれるリスクがあるため PKCE を使う。
- 認可コードは Redis に 5 分 TTL で保存、引き換え時に削除 (1 回限り)。
- モバイル系の
client_id(mobile) はprompt=select_accountを送らない限り SSO セッション cookie を発行しない。Firebase 側で長期セッションを持つので auth 側にも残すと「端末でログアウトしてもブラウザの SSO で再ログインされる」現象が起きるため。prompt=select_accountを送るアプリは SSO 自動リダイレクトの代わりに常にアカウント選択画面が出てこの現象が起きないので、cookie を発行してマルチアカウント切替に使う(prompt を送らない旧アプリバージョンを従来挙動に留めるための暫定ゲート。詳細は アカウント選択)。
ログアウトフロー
@uzupj/auth の useAuthRedirectLogout(authUrl) フックがこのフローをラップしている。
publish token (CI / 自動化の認証)
ブラウザを開けない CI から uzu publish を回すための長寿命 credential。ログイン済みユーザーが自分で発行し、RFC 8693 の token exchange で 発行者本人の uid の短命 Firebase ID Token に交換する。
設計上の要点:
- 発行者本人の uid で動く。CI 専用の合成 uid を作らないので、backend の権限モデル (
users/ScenarioMember) に架空のユーザーを持ち込まずに済む。 - 平文は DB に持たない。保存するのは SHA-256 のみで、平文は発行レスポンスにしか現れない。
- 1 本ずつ revoke できる。refresh token を CI に置く案と違い、失効はアカウント全体に波及しない。行は消さず
revoked_atを刻む。 - exchange で mint する ID Token には
publish_token: trueclaim が載る。token 管理 API (/api/publish-tokens) はこの claim を持つ token を 403 で弾くので、漏れた CI トークンから新しい token を生やして失効を回避することはできない。 - 交換した ID Token は publish 用 API でしか通らない。backend の認証 middleware は
publish_token: trueの ID Token をリクエストユーザーにせず、AllowPublishTokenを付けたルート (POST /uzu_code/upload_sessions/POST /uzu_code/scenario_revisions) でだけ認証済みにする。GraphQL / websocket では未認証、gRPC ではUnauthenticated。発行者の ID Token そのものなので、この絞り込みが無いと漏れた CI トークンで発行者のアプリ操作が一通りできてしまう。 - admin 権限は落とす。発行者が admin ユーザーでも publish token 経由では非 admin として扱う。
HasRoleの admin 分岐で、発行者がメンバーでないシナリオにまで publish できてしまうため。 - シナリオ単位のスコープは持たない。発行者が編集権限を持つシナリオすべてに publish できる (新規シナリオの作成も含む)。スコープを足すときは backend 側の enforce (Firebase custom claims +
create_scenario_revision) とセットで保存形式ごと決める。custom claims は 1000 バイト上限なので、シナリオ ID を多数載せる形は取れない。 - Firebase への直接アクセスは絞れていない。ID Token は Firestore にもそのまま使え、prd の Firestore ルールで読めるのは発行者が参加しているチャットになる。
| エンドポイント | 用途 |
|---|---|
POST /api/publish-tokens | 発行 (平文は 1 回だけ返る) |
GET /api/publish-tokens | 本人の有効な token 一覧 |
DELETE /api/publish-tokens/{id} | 失効 |
POST /api/oauth/token (grant_type=urn:ietf:params:oauth:grant-type:token-exchange) | publish token → ID Token |
CLI 側の使い方は UZU Code の開発・テストガイド を参照。
stg/prd の runtime 用 DATABASE_URL (auth_app) への publish_token の SELECT / INSERT / UPDATE は migration で付与している。auth_app が無い dev / ローカルでは何もしない。
redirect_uri ホワイトリスト
apps/auth は redirect_uri を厳密に検証する (apps/auth/src/lib/redirect.ts)。許可ホストは環境ごとに異なる。
| 環境 | 許可されるホスト |
|---|---|
prd | *.uzu-app.com / *.mdms.jp / *.uzu.one (env prefix なし) + uzu-app://auth (mobile prd) |
stg | stg.*.uzu-app.com 等 + uzuappstg://auth |
dev | dev.*.uzu-app.com 等 + localhost (http 可) + *.vercel.app + uzuappdev://auth |
新サービスのドメインを足すときは ALLOWED_HOSTS (suffix 一致) と MOBILE_SCHEMES_BY_ENV を更新する。
クライアント登録
apps/auth/src/lib/clients.ts の clients レコードに client_id とサービス情報を追加する。name / iconUrl はログイン画面の上部に「ウズ → サービス」と表示される際に使う。
const clients: Record<string, ClientConfig> = {
studio: { name: 'UZU STUDIO', iconUrl: 'https://storage.googleapis.com/uzu-auth-assets/uzustudio.png' },
admin: { name: 'UZU ADMIN', iconUrl: 'https://storage.googleapis.com/uzu-auth-assets/uzustudio.png' },
mobile: { name: 'UZU', iconUrl: 'https://storage.googleapis.com/uzu-auth-assets/uzu.png' },
// 新規追加例:
// uzutokyo: { name: 'UZU TOKYO Admin', iconUrl: '...' },
};client_id は redirect_uri ホワイトリストとは独立した「表示用 + cookie 発行ポリシー」のキー。
メールアドレス + パスワードログイン
emailPasswordLogin: true の client だけ、ログイン画面にメールアドレス + パスワードのフォームが出る。共用スタッフアカウントで使う管理画面向けで、現在は uzutokyo(UZU TOKYO Admin)と mdms_jp_shop(マダミス.jp 店舗管理)が対象。
マダミス.jp は一般向け mdms_jp・店舗管理 mdms_jp_shop・運営管理 mdms_jp_admin で client を分けている。利用規約はどれも mdmsjp_terms。
auth が持つのはログインだけ。アカウントの発行とパスワード変更は Firebase Admin(Console / Admin SDK)で行う。
- uzutokyo 用は
@sally-inc.jpでemailVerified: trueを付けて発行する。uzutokyo backend はトップレベルのemail_verifiedが true の@sally-inc.jpしか通さない(providerData へのフォールバックはgoogle.comだけ)。uzu-{env} の Firebase は email/password の自己登録が開いているので、未検証のアカウントを通すと誰でも社内アドレスを名乗れる - マダミス.jp 店舗管理は Firebase のアカウントだけでは店舗に入れない。mdms_jp の
User行は初回ログイン時にupsertUserAuthで作られ、店舗への参加は通常のスタッフと同じく招待リンク(verifyInvitationLink)で行う
フォームの出し分けは表示制御でしかない。client_id は URL で書き換えられるし、Firebase の REST からはどの client でも password ログインできる。認可は各 backend で行う。
各サービスの統合方法
Next.js アプリ (Studio / Admin / mdms.jp / 新規 Next.js プロジェクト)
@uzupj/auth パッケージをそのまま使える。詳細は javascript/packages/auth/README.md 参照。要点:
'use client';
import { AuthProvider, AuthRedirectGuard } from '@uzupj/auth';
import { auth } from '../lib/firebase';
const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL ?? '';
export const RootProviders = ({ children }: { children: React.ReactNode }) => (
<AuthProvider
fbAuth={auth}
locale="ja"
serviceName="YOUR APP"
serviceIconUrl="..."
serviceTerms={`${AUTH_URL}/legal/uzu_terms/latest`}
servicePrivacyPolicy={`${AUTH_URL}/legal/privacy/latest`}
>
<AuthRedirectGuard authUrl={AUTH_URL} clientId="yourapp">
{children}
</AuthRedirectGuard>
</AuthProvider>
);backend へのリクエストは getAuthToken() (useContext(AuthContext)) または getIdTokenInMemory() で得た ID Token を Authorization: Bearer <token> で送り、backend で firebase-admin の verifyIdToken() で検証する。
Vite SPA (uzutokyo/frontend など Next.js でないアプリ)
@uzupj/auth パッケージは内部で next/font/google / next/link / next/navigation 等の Next.js 専用モジュールを transitive 依存で持つ (Chakra UI ベースのログインモーダルや SSR 用の 'use client' 前提など)。Vite ビルドに直接持ち込むと process is not defined 等のランタイムエラーで動かない。
Vite SPA では apps/auth のフロー仕様だけを受け継ぎ、Provider は薄く自前実装する方針を取る。実装すべきものは以下:
- Firebase Web SDK 初期化:
firebase/app+firebase/authでAuthインスタンスを作る。 - Custom Token 受け取り: 起動時に URL クエリ
?firebaseAuthCustomToken=...または cookiefirebaseAuthCustomTokenを検出し、signInWithCustomToken(auth, token)を実行。完了後に URL から除去する (history.replaceState)。 - Auth state 購読:
onAuthStateChanged(auth, ...)でcurrentUserを state に持つ。 - 未ログイン時 redirect:
currentUser === null(かつ Custom Token 処理中でない) で${VITE_AUTH_URL}/login?redirect_uri=${encodeURIComponent(location.href)}&client_id=<your-client>に飛ばす。 - ID Token 露出: backend fetch ラッパが React tree 外から取得できるよう、
window.__UZU_DATA.getIdToken等にメモリ参照を置く (@uzupj/authのgetIdTokenInMemoryと同じ作法)。 - ログアウト:
auth.signOut()した後${VITE_AUTH_URL}/api/logout?redirect_uri=${encodeURIComponent(location.origin)}に redirect して SSO セッションも消す。
API 表面は @uzupj/auth と揃えておくと、将来パッケージ側が Next.js 非依存にリファクタされた段階で差し替えやすい (AuthProvider / AuthContext / AuthRedirectGuard / useAuthRedirectLogout / getIdTokenInMemory)。
Mobile (Flutter / Desktop)
PKCE フローを使う。
code_verifier(cryptographically random) を生成、code_challenge = base64url(sha256(verifier))を計算。- システムブラウザで
https://auth.uzu-app.com/login?redirect_uri=uzu-app://auth&client_id=mobile&code_challenge=...&code_challenge_method=S256を開く。 - Deep Link
uzu-app://auth?code=xxxでアプリに戻ってきたらPOST {AUTH_URL}/api/token { code, code_verifier }でcustom_tokenを取得。 signInWithCustomToken(custom_token)で Firebase Auth に署名。
カスタムスキームは環境ごとに uzuappdev:// (dev) / uzuappstg:// (stg) / uzuapp:// (prd) を使い分ける。
環境変数
apps/auth (デプロイ済みインスタンス)
| 変数 | 用途 |
|---|---|
NEXT_PUBLIC_APP_ENV | dev / stg / prd (redirect_uri 検証 + mobile scheme 切替) |
NEXT_PUBLIC_FIREBASE_* | Firebase Web SDK (ログインフォームから signIn する際に使う) |
AUTH_DOMAIN | session cookie の Domain 属性 (例: dev.auth.uzu-app.com) |
REDIS_URL | SSO セッション / 認可コード / トランスファーコード保存先 |
クライアントアプリ (Next.js)
| 変数 | 値の例 |
|---|---|
NEXT_PUBLIC_AUTH_URL | https://auth.uzu-app.com (prd), https://dev.auth.uzu-app.com (dev) |
クライアントアプリ (Vite)
| 変数 | 値の例 |
|---|---|
VITE_AUTH_URL | 同上 |
VITE_FIREBASE_API_KEY / VITE_FIREBASE_AUTH_DOMAIN / VITE_FIREBASE_PROJECT_ID / VITE_FIREBASE_APP_ID | uzu-{dev,stg,prd} の Firebase Web 設定 |
バックエンド共通
firebase-admin を applicationDefault() + projectId: uzu-${APP_ENV} で初期化し、Authorization: Bearer の ID Token を verifyIdToken で検証するだけで認可ができる。
SSO セッション設計の注意点
- セッション TTL は 400 日。
getSessionのたびにlast_accessed_atを更新し TTL を伸ばすので、定期的にアクセスがあるユーザーは事実上無期限ログイン。 - mobile クライアントは
prompt=select_accountを送ったときだけ cookie を発行する (apps/auth/src/app/api/callback/route.tsのCLIENTS_WITHOUT_SESSION)。prompt なし(旧アプリバージョン)では端末ログアウトと SSO セッションが食い違う事故を避けるため発行しない。prompt ありでは SSO 自動リダイレクトの代わりに常にアカウント選択画面が出るため発行してよい。 auth_sessioncookie は HttpOnly + SameSite=Lax + Secure (localhost を除く)。DomainはAUTH_DOMAINenv で制御。- ローカルでは
AUTH_DOMAIN=localhostで動かす想定だが、デフォルトでは dev の auth サーバ (https://dev.auth.uzu-app.com) を共有する運用 (各クライアントの README を参照)。
電話番号認証 (SMS) の監視
「特定の国 / キャリアの端末で電話番号認証ができない」「SMS が届かない」「認証コードが期限切れになる」といった問い合わせが来たときは、まず Cloud Monitoring の以下のダッシュボードで国別の SMS 配信状況を確認する。
- Firebase Auth SMS 国別失敗数 (uzu-prd)
主要なパネル:
| パネル | 見るポイント |
|---|---|
国別 SMS 送信失敗数 上位5国 (sent - verified, 1日合計) | 失敗が集中している国を素早く把握する |
国別 SMS 認証成功率 (verified / sent) | 75% を下回ったら要注意 (許容ライン threshold あり) |
国別 SMS 送信数 (sent) / 認証成功数 (verified) | キャンペーン等で送信ボリュームが急増していないか確認 |
国別 SMS ブロック数 (blocked) | SMS pumping 対策で Firebase 側が送信前に弾いた件数 |
使い分け
個別ユーザーの調査 (UID 指定で SMS 送信ログを追う) は Cloud Logging で行い、このダッシュボードは 国 / 地域単位での傾向把握 に使う。例えば韓国 (KR) からの問い合わせが続いているときに、KR 全体で成功率が落ちているのか、特定ユーザーだけの問題なのかを切り分けるのに有効。
なお、recaptcha (Firebase App Check 経由の Phone Auth 保護) の導入は 2026-05-13 時点で見送っている。SMS 不達のレポートが増えた場合は、まずこのダッシュボードで配信元の問題かを確認してから個別調査に進むこと。
認証ファネルのログ (クライアント計装)
Identity Platform の Cloud Audit Logs は SignInWithPassword や電話認証系を「高ボリュームメソッド」として記録対象外にしている。ログイン失敗をサーバー側で拾う手段は存在しないので、クライアントで捕捉して auth のログ受け口へ送る。
構成
| 部品 | 場所 | 役割 |
|---|---|---|
logAuthEvent / configureAuthLog | packages/auth/src/authLog.ts | Firebase Auth 呼び出しの成否を送る。sendBeacon 優先、失敗時 keepalive fetch |
POST /api/log | apps/auth/src/app/api/log/route.ts | 受け取った内容を stdout へ構造化 JSON で吐くだけ。GKE なので Fluent Bit が jsonPayload として Cloud Logging に載せる |
initAuthLog() | apps/auth/src/lib/auth-log.ts | 送信先を /api/log に設定する |
@uzupj/auth は studio / admin / mdms_jp にも読み込まれるが、それらのオリジンに受け口は無い。パッケージ側は既定で送信せず、configureAuthLog を呼んだアプリ (現状 apps/auth のみ) でだけ有効になる。他アプリへ広げる場合は絶対 URL を渡したうえで /api/log 側に CORS が要る。
フィールド
backend / mobile / mdms_jp と同じ横断フィールド (severity / message / env / origin / uid) を持たせてある。origin は Slack のエラー通知が summaryFields に使っているキーなので、サービス識別はここで引ける。ブラウザ由来のログは auth-web。
{
"severity": "WARNING",
"message": "auth phone_otp send_code failure (auth/too-many-requests)",
"origin": "auth-web",
"env": "prd",
"event": "client-log",
"evt": {
"category": "authentication",
"name": "phone_otp",
"action": "send_code",
"outcome": "failure",
"surface": "signIn"
},
"error": { "code": "auth/too-many-requests", "name": "FirebaseError", "message": "..." },
"knownIssue": null,
"sessionId": "…",
"uid": null,
"page": { "path": "/login", "clientId": "studio" },
"client": { "userAgent": "…" }
}evt.name:phone_otp/google/twitter/facebook/apple/custom_token/email_password/recaptcha/unknownevt.action:send_code/verify_code/render/solve/expire/redirect_start/redirect_result/sign_in/link/unlink/deleteevt.surface:signIn/signUp/linkseverity/origin/env/eventはサーバー側が決める。クライアントが同名を送っても上書きされないknownIssue: ライブラリ側の既知バグを自前の失敗と混ぜないためのタグ。閉窓後の null style エラー (firebase-js-sdk#8844) をfirebase-js-sdk#8844として立てる。expired-callback不発火 (#7631) はevt.action="expire"の件数が 0 のままかで観測する
evt.outcome を success / failure で数えれば、プロバイダ別・画面別のログイン成功率をログベース指標にできる。redirect_start と redirect_result の差はソーシャルログインの離脱数になる。
evt.action="delete" は「未連携で作られた一時ユーザーの後始末」で user.delete() が発火した記録。既存ユーザーに対して誤発火すると UZU アカウントだけが残って Firebase Auth の認証情報が消える orphan になるため、uid で必ず後追いできるようにしてある。orphan の問い合わせが来たら、まずこの action を該当 uid で引く。
個人情報の扱い
- URL 全体は送らない。クライアントアプリの復帰 URL には
firebaseAuthCustomTokenが載るため、page.pathとclient_idだけに絞っている - 電話番号・メールアドレスは送らない
- IP はログに残さない。レート制限のキーにだけ使う
入口の制限
無認証で誰でも叩ける endpoint なので、/api/log は 3 つの制限を持つ。
| 制限 | 値 | 超過時 |
|---|---|---|
| ペイロードサイズ | 16KiB (sendBeacon 上限 64KiB の手前で切る) | 413 |
| レート (IP 単位) | 60 件 / 分 | 429 |
| レート (Pod 単位) | 1200 件 / 分 | 429 |
| 構造 | 深さ 4 / オブジェクト 30 キー / 配列 20 要素 / 文字列 512 文字 | 超過分を切り捨て |
severity / timestamp / message / logging.googleapis.com/* などログエージェントが特別扱いするキーは、クライアントから送られても落とす (重大度の詐称を防ぐため)。カウンタは Pod ローカルなので、実効上限は Pod 数だけ倍率がかかる。ログ流量の頭打ちが目的なので Redis で厳密化はしていない。
Cloud Logging での見方
jsonPayload.origin="auth-web"
jsonPayload.evt.category="authentication"
jsonPayload.evt.outcome="failure"問い合わせ調査では jsonPayload.error.code (例: auth/captcha-check-failed) と jsonPayload.client.userAgent を突き合わせる。特定 OS / ブラウザに偏っていれば環境依存、全体に散っていれば配信側の問題として SMS 国別ダッシュボードへ進む。
関連リソース
- マルチアカウントとアカウント選択 (prompt=select_account) — 複数セッション保持とアカウント選択画面の設計
- Linear プロジェクト: 認証基盤の導入 (UZU team, Lead: Kenta Imai)
- 仕様書 (Notion): v2 設計
- パッケージ README:
javascript/packages/auth/README.md - 関連実装 PR:
henteko/studio_auth/henteko/studio_editor_auth/henteko/mdmsjp_auth/henteko/mobile_auth_server - 旧方式のドキュメント: 認証まわり (legacy) —
account.uzu-app.com+ iframe / postMessage 方式の名残り