構成
Saruには4つのポータルがあります:System、Provider、Reseller、Consumer。それぞれ異なるサブドメインで動作しますが、1つのKeycloakレルムを共有しています。
system.saru.local (port 3001) → Keycloak
provider.saru.local (port 3002) → (単一レルム、
reseller.saru.local (port 3003) → 4クライアント)
consumer.saru.local (port 3004) →
基本的なKeycloak + Auth.js統合については既存のチュートリアルで十分にカバーされています。この記事では、それらのチュートリアルでは触れられない問題を扱います。
落とし穴1: サブドメイン間のCookie競合
問題
将来的な利用を見越して、サブドメイン間でセッションを共有したいと考え、以下のように設定しました:
| |
結果:System PortalにログインするとProvider Portalでも同じセッションが表示されました。しかしそれは間違ったユーザーコンテキストでした。System管理者のトークンがProvider Portalで使用されていたのです。
なぜ起こるか
Auth.jsはauthjs.session-token(HTTPSでは__Secure-authjs.session-token)のようなCookie名を使用します。domain: '.saru.local'を設定すると、すべてのサブドメインが同じCookieを共有します。最初にCookieを設定したポータルが勝ち、他のポータルでの後続のログインでも同じCookieが読み取られます。
注: Cookie名はAuth.jsのバージョンと設定によって異なります(例:古いバージョンでは
next-auth.session-token)。ブラウザDevToolsで実際のCookie名を確認してください。
system.saru.local → authjs.session-token設定 (domain=.saru.local)
provider.saru.local → 同じCookieを読み取る → 間違ったコンテキスト!
解決策
ポータルプレフィックス付きのCookie名を使用:
| |
注: Auth.jsはOAuthフロー用に追加のCookie(
state、pkceCodeVerifier)を使用します。複数のポータルが同時にログインを行う場合、これらにもプレフィックスを付けることで断続的な認証失敗を防げます。
これで各ポータルが独自のセッションを持つようになりました:
system.saru.local → authjs.system-session-token
provider.saru.local → authjs.provider-session-token ✓
教訓
サブドメイン間の共有が不要なら、domainを省略してください。Cookieはデフォルトでホスト専用になり、この問題を回避できます。
落とし穴2: カスタムクレームがトークンに含まれない
問題
バックエンドにはテナントコンテキストが必要です:account_id、account_type、capabilities。これらをKeycloakのユーザー属性として保存しましたが、トークンには含まれていませんでした。
| |
なぜ起こるか
Keycloakはユーザー属性を自動的にトークンに含めません。Protocol Mappersが必要です。さらに、いくつかの注意点があります。
解決策
ステップ1: Protocol Mappersの作成
各属性に対して、Keycloakでマッパーを作成:
| 設定 | 値 |
|---|---|
| Mapper Type | User Attribute |
| User Attribute | account_id |
| Token Claim Name | account_id |
| Add to ID token | ON |
| Add to access token | ON(APIがアクセストークンを検証する場合) |
| Add to userinfo | ON |
重要: マッパーをClient Scopeに追加する場合、そのスコープがクライアントに割り当てられていること(デフォルトまたはオプショナルとして)を確認してください。そうでなければマッパーは実行されません。
ステップ2: 複数値属性を正しく扱う
capabilities(文字列の配列)を最初はJSON文字列として保存していました:
| |
Keycloakの「Multivalued」マッパーは、JSON文字列ではなく個別の値を期待します。重要: マッパー設定で「Multivalued」をONにしてください。
| |
Keycloak Admin APIはattributesマップ内で配列を直接受け付けます:
| |
ステップ3: カスタムスコープ(見落としがち)
scope: 'openid roles account_info'をリクエストする場合、account_infoのようなカスタムスコープはKeycloakに存在する必要があります。標準OIDCはopenid、profile、emailのみ提供します。
注:
rolesは標準OIDCスコープではなく、ロールマッパーを持つKeycloakクライアントスコープです。デフォルトのクライアントスコープにロールマッパーが含まれている場合、rolesスコープを明示的にリクエストしなくてもロールがトークンに含まれることがあります。ただし、rolesをスコープとして明示的にリクエストする場合は、rolesクライアントスコープがクライアントに割り当てられていることを確認してください。
Keycloakでaccount_infoのようなカスタムスコープのClient Scopesを作成し、クライアントに割り当てる必要があります。存在しないまたは未割り当てのスコープは静かに無視され、トークンに期待したクレームが含まれなくなります。
教訓
早い段階でトークンの内容をテストしてください。ローカルでトークンをデコードし(例:jwt-cli、ブラウザDevTools、ローカルスクリプト)、フロントエンドコードを書く前にクレームが存在することを確認しましょう。
セキュリティ注意: 本番トークンをjwt.ioのようなオンラインデコーダーに貼り付けないでください。サードパーティサービスです。実際のトークンにはローカルツールを使用してください。
落とし穴3: セッション内のトークン公開
問題
APIを直接呼び出すため、クライアント側でアクセストークンが必要でした:
| |
前提条件:
token.accessTokenが存在するためには、サインイン時のjwtコールバックで先に保存しておく必要があります(例:token.accessToken = account.access_token)。refreshTokenも同様です。
これは動作しますが、最初は十分に検討しなかったセキュリティ上のトレードオフがあります。
なぜリスクか
セッションにaccessTokenがあると、ページ上のどのJavaScriptからもアクセス可能です:
| |
XSS脆弱性があれば、攻撃者がトークンを盗めます。
トレードオフ
| アプローチ | メリット | デメリット |
|---|---|---|
| トークン公開 | シンプル、ブラウザから直接API呼び出し | XSSでトークン盗難の可能性 |
| BFFパターン | トークンはサーバーサイドに留まり、クライアントはBFFのみ呼び出す | 複雑さ増加、全トラフィックがNext.js経由 |
注: 「全呼び出しをプロキシ」は本質的にBFFパターンです。重要な問いは、クライアントがbearerトークンを保持するかどうかです。
Saruの選択
リスクを受け入れつつ、多層防御策を講じています:
- 厳格なCSP: 実行可能なスクリプトを制限(完全ではないが、攻撃面を縮小)
- 短い有効期限: トークンは5分で期限切れ(盗難時のダメージウィンドウを制限)
- リフレッシュトークンローテーション: リフレッシュごとに新しいリフレッシュトークンを発行(Keycloakのレルム/クライアント設定で「Revoke Refresh Token」を有効にする必要があります)
正直な評価: これらの緩和策はリスクを軽減しますが、排除はしません。XSS脆弱性があれば、トークンの有効期間中は完全なアカウント侵害を意味します。リフレッシュトークンもセッションに公開している場合、攻撃者は5分のウィンドウを超えてアクセスを延長できます。私たちはユーザー生成コンテンツがないB2Bで信頼できるユーザーという文脈でこのトレードオフを受け入れています。
| |
教訓
普遍的な「正解」はありません。脅威モデルを理解してください。信頼できるユーザーのB2B SaaSなら、緩和策付きのトークン公開は許容範囲内です。ユーザー生成コンテンツ(XSSリスク)のあるコンシューマーアプリなら、BFFを検討してください。
おまけ: トークンリフレッシュのエラー処理
もう1つハマったこと:リフレッシュ失敗を適切に処理する方法。
| |
アプリでの処理:
| |
まとめ
| 落とし穴 | 解決策 |
|---|---|
| Cookie競合 | ポータルプレフィックス付きCookie名 |
| クレームが含まれない | Protocol Mappers + 正しい属性フォーマット |
| トークン公開 | トレードオフを受け入れて緩和策を講じる、またはBFF |
Keycloak + Auth.jsの基本は十分にドキュメント化されています。デバッグに時間がかかるのはこれらのエッジケースです。この記事がその時間を節約する助けになれば幸いです。
シリーズ記事