Skip to content

認証基盤 (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/authjavascript/apps/authNext.js 16 製の中央 SSO サーバー (auth.uzu-app.com)。ログイン UI 提供 + Firebase ID Token を受け取り Custom Token / 認可コードを発行 + Redis で SSO セッション管理
@uzupj/authjavascript/packages/auth各 React アプリが import する共通 Provider。AuthProvider / AuthContext / AuthRedirectGuard / useAuthRedirectLogout / getIdTokenInMemory を export
Firebase AuthenticationGCP プロジェクト 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_session cookie で管理。ドメイン 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: true claim が載る。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)
stgstg.*.uzu-app.com 等 + uzuappstg://auth
devdev.*.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 はログイン画面の上部に「ウズ → サービス」と表示される際に使う。

ts
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 参照。要点:

tsx
'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 は薄く自前実装する方針を取る。実装すべきものは以下:

  1. Firebase Web SDK 初期化: firebase/app + firebase/auth で Auth インスタンスを作る。
  2. Custom Token 受け取り: 起動時に URL クエリ ?firebaseAuthCustomToken=... または cookie firebaseAuthCustomToken を検出し、signInWithCustomToken(auth, token) を実行。完了後に URL から除去する (history.replaceState)。
  3. Auth state 購読: onAuthStateChanged(auth, ...) で currentUser を state に持つ。
  4. 未ログイン時 redirect: currentUser === null (かつ Custom Token 処理中でない) で ${VITE_AUTH_URL}/login?redirect_uri=${encodeURIComponent(location.href)}&client_id=<your-client> に飛ばす。
  5. ID Token 露出: backend fetch ラッパが React tree 外から取得できるよう、window.__UZU_DATA.getIdToken 等にメモリ参照を置く (@uzupj/auth の getIdTokenInMemory と同じ作法)。
  6. ログアウト: 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 フローを使う。

  1. code_verifier (cryptographically random) を生成、code_challenge = base64url(sha256(verifier)) を計算。
  2. システムブラウザで https://auth.uzu-app.com/login?redirect_uri=uzu-app://auth&client_id=mobile&code_challenge=...&code_challenge_method=S256 を開く。
  3. Deep Link uzu-app://auth?code=xxx でアプリに戻ってきたら POST {AUTH_URL}/api/token { code, code_verifier } で custom_token を取得。
  4. signInWithCustomToken(custom_token) で Firebase Auth に署名。

カスタムスキームは環境ごとに uzuappdev:// (dev) / uzuappstg:// (stg) / uzuapp:// (prd) を使い分ける。

環境変数 ​

apps/auth (デプロイ済みインスタンス) ​

変数用途
NEXT_PUBLIC_APP_ENVdev / stg / prd (redirect_uri 検証 + mobile scheme 切替)
NEXT_PUBLIC_FIREBASE_*Firebase Web SDK (ログインフォームから signIn する際に使う)
AUTH_DOMAINsession cookie の Domain 属性 (例: dev.auth.uzu-app.com)
REDIS_URLSSO セッション / 認可コード / トランスファーコード保存先

クライアントアプリ (Next.js) ​

変数値の例
NEXT_PUBLIC_AUTH_URLhttps://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_IDuzu-{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_session cookie は HttpOnly + SameSite=Lax + Secure (localhost を除く)。Domain は AUTH_DOMAIN env で制御。
  • ローカルでは AUTH_DOMAIN=localhost で動かす想定だが、デフォルトでは dev の auth サーバ (https://dev.auth.uzu-app.com) を共有する運用 (各クライアントの README を参照)。

電話番号認証 (SMS) の監視 ​

「特定の国 / キャリアの端末で電話番号認証ができない」「SMS が届かない」「認証コードが期限切れになる」といった問い合わせが来たときは、まず Cloud Monitoring の以下のダッシュボードで国別の SMS 配信状況を確認する。

主要なパネル:

パネル見るポイント
国別 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 / configureAuthLogpackages/auth/src/authLog.tsFirebase Auth 呼び出しの成否を送る。sendBeacon 優先、失敗時 keepalive fetch
POST /api/logapps/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。

json
{
  "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 / unknown
  • evt.action: send_code / verify_code / render / solve / expire / redirect_start / redirect_result / sign_in / link / unlink / delete
  • evt.surface: signIn / signUp / link
  • severity / 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 国別ダッシュボードへ進む。

関連リソース ​