構成

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競合

問題

将来的な利用を見越して、サブドメイン間でセッションを共有したいと考え、以下のように設定しました:

1
2
3
4
5
6
7
cookies: {
  sessionToken: {
    options: {
      domain: '.saru.local',  // サブドメイン間で共有
    },
  },
}

結果: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名を使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// packages/auth/src/config.ts

export function getAuthConfig({ portal }: { portal: PortalType }) {
  return {
    cookies: {
      sessionToken: {
        name: `authjs.${portal}-session-token`,  // 例: authjs.system-session-token
        options: {
          domain: COOKIE_DOMAIN,
        },
      },
      callbackUrl: {
        name: `authjs.${portal}-callback-url`,
      },
      csrfToken: {
        name: `authjs.${portal}-csrf-token`,
      },
      // OAuthフローを使用する場合はstate/pkceCodeVerifierもプレフィックス
    },
    // ...
  };
}

注: Auth.jsはOAuthフロー用に追加のCookie(statepkceCodeVerifier)を使用します。複数のポータルが同時にログインを行う場合、これらにもプレフィックスを付けることで断続的な認証失敗を防げます。

これで各ポータルが独自のセッションを持つようになりました:

system.saru.local   → authjs.system-session-token
provider.saru.local → authjs.provider-session-token  ✓

教訓

サブドメイン間の共有が不要なら、domainを省略してください。Cookieはデフォルトでホスト専用になり、この問題を回避できます。

落とし穴2: カスタムクレームがトークンに含まれない

問題

バックエンドにはテナントコンテキストが必要です:account_idaccount_typecapabilities。これらをKeycloakのユーザー属性として保存しましたが、トークンには含まれていませんでした。

1
2
3
// profileに含まれることを期待
profile.account_id      // undefined
profile.capabilities    // undefined

なぜ起こるか

Keycloakはユーザー属性を自動的にトークンに含めません。Protocol Mappersが必要です。さらに、いくつかの注意点があります。

解決策

ステップ1: Protocol Mappersの作成

各属性に対して、Keycloakでマッパーを作成:

設定
Mapper TypeUser Attribute
User Attributeaccount_id
Token Claim Nameaccount_id
Add to ID tokenON
Add to access tokenON(APIがアクセストークンを検証する場合)
Add to userinfoON

重要: マッパーをClient Scopeに追加する場合、そのスコープがクライアントに割り当てられていること(デフォルトまたはオプショナルとして)を確認してください。そうでなければマッパーは実行されません。

ステップ2: 複数値属性を正しく扱う

capabilities(文字列の配列)を最初はJSON文字列として保存していました:

1
2
// 間違い!文字列リテラルを保存している
{ "attributes": { "capabilities": "[\"CONSUME\", \"PROVIDE\"]" } }

Keycloakの「Multivalued」マッパーは、JSON文字列ではなく個別の値を期待します。重要: マッパー設定で「Multivalued」をONにしてください。

1
2
// 正しい: Keycloak Admin APIユーザー更新ペイロード
{ "attributes": { "capabilities": ["CONSUME", "PROVIDE"] } }

Keycloak Admin APIはattributesマップ内で配列を直接受け付けます:

1
2
3
4
5
6
// Admin API経由でアプリからcapabilitiesを同期する際
userUpdate := map[string]interface{}{
    "attributes": map[string][]string{
        "capabilities": {"CONSUME", "PROVIDE"},  // JSON文字列ではなく配列
    },
}

ステップ3: カスタムスコープ(見落としがち)

scope: 'openid roles account_info'をリクエストする場合、account_infoのようなカスタムスコープはKeycloakに存在する必要があります。標準OIDCはopenidprofileemailのみ提供します。

注: rolesは標準OIDCスコープではなく、ロールマッパーを持つKeycloakクライアントスコープです。デフォルトのクライアントスコープにロールマッパーが含まれている場合、rolesスコープを明示的にリクエストしなくてもロールがトークンに含まれることがあります。ただし、rolesをスコープとして明示的にリクエストする場合は、rolesクライアントスコープがクライアントに割り当てられていることを確認してください。

Keycloakでaccount_infoのようなカスタムスコープのClient Scopesを作成し、クライアントに割り当てる必要があります。存在しないまたは未割り当てのスコープは静かに無視され、トークンに期待したクレームが含まれなくなります。

教訓

早い段階でトークンの内容をテストしてください。ローカルでトークンをデコードし(例:jwt-cli、ブラウザDevTools、ローカルスクリプト)、フロントエンドコードを書く前にクレームが存在することを確認しましょう。

セキュリティ注意: 本番トークンをjwt.ioのようなオンラインデコーダーに貼り付けないでください。サードパーティサービスです。実際のトークンにはローカルツールを使用してください。

落とし穴3: セッション内のトークン公開

問題

APIを直接呼び出すため、クライアント側でアクセストークンが必要でした:

1
2
3
4
5
6
7
// Session callback - トークンをクライアントに公開
async session({ session, token }) {
  return {
    ...session,
    accessToken: token.accessToken,  // クライアントJSに公開
  };
}

前提条件: token.accessTokenが存在するためには、サインイン時のjwtコールバックで先に保存しておく必要があります(例:token.accessToken = account.access_token)。refreshTokenも同様です。

これは動作しますが、最初は十分に検討しなかったセキュリティ上のトレードオフがあります。

なぜリスクか

セッションにaccessTokenがあると、ページ上のどのJavaScriptからもアクセス可能です:

1
2
const { data: session } = useSession();
console.log(session.accessToken);  // どのスクリプトでも可能

XSS脆弱性があれば、攻撃者がトークンを盗めます。

トレードオフ

アプローチメリットデメリット
トークン公開シンプル、ブラウザから直接API呼び出しXSSでトークン盗難の可能性
BFFパターントークンはサーバーサイドに留まり、クライアントはBFFのみ呼び出す複雑さ増加、全トラフィックがNext.js経由

注: 「全呼び出しをプロキシ」は本質的にBFFパターンです。重要な問いは、クライアントがbearerトークンを保持するかどうかです。

Saruの選択

リスクを受け入れつつ、多層防御策を講じています:

  1. 厳格なCSP: 実行可能なスクリプトを制限(完全ではないが、攻撃面を縮小)
  2. 短い有効期限: トークンは5分で期限切れ(盗難時のダメージウィンドウを制限)
  3. リフレッシュトークンローテーション: リフレッシュごとに新しいリフレッシュトークンを発行(Keycloakのレルム/クライアント設定で「Revoke Refresh Token」を有効にする必要があります)

正直な評価: これらの緩和策はリスクを軽減しますが、排除はしません。XSS脆弱性があれば、トークンの有効期間中は完全なアカウント侵害を意味します。リフレッシュトークンもセッションに公開している場合、攻撃者は5分のウィンドウを超えてアクセスを延長できます。私たちはユーザー生成コンテンツがないB2Bで信頼できるユーザーという文脈でこのトレードオフを受け入れています。

1
2
3
4
5
6
// リフレッシュトークンローテーション付きトークン更新
return {
  accessToken: refreshedTokens.access_token,
  refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
  expiresAt: Math.floor(Date.now() / 1000) + refreshedTokens.expires_in,
};

教訓

普遍的な「正解」はありません。脅威モデルを理解してください。信頼できるユーザーのB2B SaaSなら、緩和策付きのトークン公開は許容範囲内です。ユーザー生成コンテンツ(XSSリスク)のあるコンシューマーアプリなら、BFFを検討してください。

おまけ: トークンリフレッシュのエラー処理

もう1つハマったこと:リフレッシュ失敗を適切に処理する方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 注: client_secretはコンフィデンシャルクライアント(サーバーサイド)用です。
// 公開クライアント(SPA)の場合は、client_secretなしでPKCEを使用します。
async function refreshAccessToken(token: JWT): Promise<JWT> {
  const response = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: token.refreshToken as string,
      client_id: clientId,
      client_secret: clientSecret,  // コンフィデンシャルクライアントのみ
    }),
  });

  // 防御的: 一部のエラーレスポンスはJSONでない場合がある
  let refreshedTokens;
  try {
    refreshedTokens = await response.json();
  } catch {
    return { ...token, error: 'TokenRefreshFailed' };
  }

  if (!response.ok) {
    // 単にthrowせず、エラー状態を返す
    const error = refreshedTokens.error;
    const errorDesc = refreshedTokens.error_description || '';

    // ユーザー関連エラーをチェック
    if (errorDesc.includes('deleted') || errorDesc.includes('disabled')) {
      return { ...token, error: 'UserDeleted' };
    }
    // invalid_grantは期限切れ/失効したリフレッシュトークンをカバー
    if (error === 'invalid_grant') {
      return { ...token, error: 'TokenRefreshFailed' };
    }
    return { ...token, error: 'TokenRefreshFailed' };
  }

  // 防御的: エッジケースでexpires_inが存在しない可能性
  const expiresIn = refreshedTokens.expires_in ?? 300; // デフォルト5分

  return {
    ...token,
    accessToken: refreshedTokens.access_token,
    refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
    expiresAt: Math.floor(Date.now() / 1000) + expiresIn,
  };
}

アプリでの処理:

1
2
3
4
5
6
const { data: session } = useSession();

if (session?.error === 'TokenRefreshFailed') {
  // 意味不明なエラーを表示する代わりに再ログインを強制
  signIn('keycloak');
}

まとめ

落とし穴解決策
Cookie競合ポータルプレフィックス付きCookie名
クレームが含まれないProtocol Mappers + 正しい属性フォーマット
トークン公開トレードオフを受け入れて緩和策を講じる、またはBFF

Keycloak + Auth.jsの基本は十分にドキュメント化されています。デバッグに時間がかかるのはこれらのエッジケースです。この記事がその時間を節約する助けになれば幸いです。


シリーズ記事