0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React SPA のフロントエンドもサブドメインでテナント分離してみた

0
Posted at

はじめに

前回、Amazon CloudFront のマルチテナントディストリビューションと AWS Lambda の Lambda Authorizer で、バックエンド API のテナント分離を実装してみました。

前作は API 層でのテナント分離のみでしたので、フロントエンドのログイン部分には課題が残っていました。

全テナントで 1 つの Amazon Cognito User Pool を共有し、Hosted UI のセッションも共有されるため、tenant-a のユーザーが tenant-b のサイトにログインできてしまいます。

今回はその続編として、フロント(React SPA)と認証まわりを、API と同じサブドメイン識別の方法でテナント分離してみました。

自分はフロントエンドにあまり詳しくない立場なので、SPA まわりは手探りでしたが、設計としてはシンプルに落とし込めたかなと思います。

リポジトリは前作と同じものです。今回はそこに追加した分の解説になります。

この記事で学べること

  • API と同じく、サブドメインにテナント識別子を入れて URL を分ける方法({tenant}.example.com)
  • テナント分離を Cognito の App Client 単位で行う構成

前提知識・条件

  • 前作(API 編)の構成を前提にします。CloudFront マルチテナントディストリビューション、Amazon API Gateway、Lambda Authorizer あたりは前作を参照してください。
  • フロントは React SPA(Vite ビルド、Amplify Auth v6、TanStack Router)です。
  • 検証環境: アジアパシフィック(東京)リージョン(ap-northeast-1)、2026 年 9 月時点。

何を作るか(全体像)

やることは 3 つです。

  1. サブドメインにテナント識別子を入れて URL を分ける({tenant}.example.com)
  2. Cognito のテナント分離を App Client 単位で行う
  3. 権限がないユーザーがログインすると Forbidden ページへ飛ばす

後述する部分もありますが、図にするとこんな感じです。

[ブラウザ] https://{tenant}.example.com
   |  ① 起動時に /tenant-config.json を取得(Host から自テナント分だけ返る)
   |     → userPoolId / userPoolClientId / hostedUiDomain を実行時に解決
   v
[Amazon Cognito Hosted UI] ログイン(App Client はテナントごと)
   |  ② 認証後、ID トークン(custom:tenantId 付き)を取得
   v
[フロント認証ガード] トークンの custom:tenantId と現在ホストのテナントを突合
   ├─ 一致          → ダッシュボード表示(/api/me は前作どおり 200)
   └─ 別テナント    → /forbidden へ(画面に入れない。/api/me は前作どおり 403)

ポイントは、テナント識別の起点をあくまで「今どのサブドメインを開いているか」に置くところです。API 編と同じ考え方にしました。

サブドメインにテナント識別子を入れて URL を分ける(やったこと①)

API 編と同じく、{tenant}.example.com でテナントを表します。

フロントも同じサブドメインに同居させ、CloudFront が Host からテナントを解決します。

前作では {tenant}.example.com/health のようにサブドメイン直下で API を受けていましたが、同じオリジンに SPA を同居させると、SPA のルーティング(/ や /forbidden など)と API のパスがぶつかります。

そこで API を {tenant}.example.com/api/health のように /api プレフィックスへ寄せ、CloudFront のふるまいで /api/* を API Gateway、それ以外を SPA(S3)へ振り分けるようにしています。

フロントからは相対パスの /api/... を叩くだけで済みます。

フロント側は「今どのテナントのサブドメインを開いているか」を起点にします。この tenantId を起動時に確定させ、後段の App Client 選択と権限チェックに使う、という流れです。

起動時のテナント解決は packages/web/src/lib/amplify.ts にあります。/tenant-config.json を fetch し、返ってきた設定で Amplify を構成します。

packages/web/src/lib/amplify.ts
export async function configureAmplify(): Promise<void> {
  const cfg = await fetchTenantConfig();
  currentTenantId = cfg.tenantId;

  // OAuth のリダイレクト先は「今アプリを開いているオリジン」を実行時に決める。
  const redirectUrl = window.location.origin;

  Amplify.configure({
    Auth: {
      Cognito: {
        userPoolId: cfg.userPoolId,
        userPoolClientId: cfg.userPoolClientId,
        loginWith: {
          oauth: {
            domain: cfg.hostedUiDomain,
            scopes: ['openid', 'email', 'profile'],
            redirectSignIn: [redirectUrl],
            redirectSignOut: [redirectUrl],
            responseType: 'code',
          },
        },
      },
    },
  });
}

解決した tenantId は getCurrentTenantId() で後段から参照できるようにしています。

テナント分離は App Client 単位(やったこと②)

現状の Cognito は、1 つの User Pool を全テナントで共有する Pooled モデルです。

ここからテナントごとに App Client を作る App Client per tenant 方式に寄せました。

callback / logout URL を自テナントのオリジンに限定し、他テナントのドメインへリダイレクトさせないためです。

App Client の作成は packages/cdk/lib/constructs/auth.ts の tenantClients ループで行っています。

packages/cdk/lib/constructs/auth.ts
// テナントごとに App Client を作る。callback はそのテナントのオリジンに限定。
const clients = new Map<string, cognito.UserPoolClient>();
for (const tenant of props.tenantClients) {
  const client = this.userPool.addClient(`AppClient-${tenant.tenantId}`, {
    userPoolClientName: `tenant-${tenant.tenantId}`,
    authFlows: { userSrp: true },
    oAuth: useHostedUi
      ? {
          flows: { authorizationCodeGrant: true },
          scopes: [/* ... openid / email / profile ... */],
          callbackUrls: [...tenant.callbackOrigins],
          logoutUrls: [...tenant.callbackOrigins],
        }
      : undefined,
    // ...
  });
  clients.set(tenant.tenantId, client);
}
this.userPoolClients = clients;

callbackUrls / logoutUrls にそのテナントの callbackOrigins(自サブドメインなど)だけを渡し、他テナントのドメインは混ぜません。

これでテナント(=クライアント)単位で、分離をしています。

フロントは自テナントの clientId を実行時に取得する

App Client をテナントごとに分けたので、フロントは「今アクセスしているホストに対応する clientId」を知る必要が出てきます。

ところが SPA は単一 dist を全テナントへ配信するため、全てのテナントごとの clientId を載せることができません。

そこで、CloudFront Function が /tenant-config.json を Host から動的生成して返す構成にしました。

packages/cdk/lib/functions/tenant-config.js の handler が、Host からテナントを解決し、そのテナント分だけを早期レスポンスで返します。

packages/cdk/lib/functions/tenant-config.js
function handler(event) {
  var request = event.request;
  var host = request.headers.host ? request.headers.host.value : '';
  var baseDomain = '__BASE_DOMAIN__';
  var tenantConfig = __TENANT_CLIENT_MAP__;
  var suffix = '.' + baseDomain;
  var tenant = '';

  if (host.length > suffix.length && host.slice(-suffix.length) === suffix) {
    var label = host.slice(0, host.length - suffix.length);
    // 単一ラベルのみ許可(さらにネストしたサブドメインは弾く)
    if (label.length > 0 && label.indexOf('.') === -1) {
      tenant = label;
    }
  }

  var cfg = tenant && tenantConfig[tenant] ? tenantConfig[tenant] : null;
  if (!cfg) {
    return { statusCode: 404, /* ... "Unknown tenant" ... */ };
  }

  // 返すのは「このテナント1件」の公開設定のみ。
  var body = JSON.stringify({
    tenantId: tenant,
    userPoolId: cfg.userPoolId,
    userPoolClientId: cfg.userPoolClientId,
    hostedUiDomain: cfg.hostedUiDomain,
  });

  return { statusCode: 200, /* ... */ body: body };
}

返すのは Host に対応する 1 テナント分だけで、全テナントのマップは返しません。

仮に Host を偽って別のサブドメインを名乗られても、そのテナントの公開設定が返るだけです。

そもそも返しているのは clientId など秘密でない値(public client は client secret を持たない)なので、他テナントの設定を引ければ困る、という性質のものではありません。

全テナントのマップを返さないのは、テナント一覧をまとめて列挙されることでを防ぐための配慮です。

多層防御として Authorizer でも aud を突合

App Client を分けたことで、 API の認可側にも 1 段防御を足せます。

packages/authorizer/src/index.ts で、tenantId 突合の直後に aud(トークンを発行した App Client)も突合しています。

packages/authorizer/src/index.ts
const payload = await verifier.verify(token);
const jwtTenantId = payload[TENANT_CLAIM];

// (1) JWT 由来のテナントIDと Host 由来のテナントIDが一致しなければ拒否。
if (typeof jwtTenantId !== 'string' || jwtTenantId !== hostTenantId) {
  return deny(event);
}

// (2) テナント↔clientId の突合。トークンを発行した App Client が、その
// テナントに割り当てられた App Client か検証する。
const expectedClientId = TENANT_CLIENT_MAP[jwtTenantId];
if (!expectedClientId || payload.aud !== expectedClientId) {
  return deny(event);
}

仮に別テナントの App Client で発行されたトークンが来ても、aud が一致しなければ (2) で弾けます。前作の tenantId 突合に加えた多層防御ですね。

補足:App Client を増やして大丈夫?(クォータ)

テナントごとに App Client を作ると数が増えます。App clients per user pool のクォータはデフォルト 1,000 で、緩和申請で引き上げられます。

テナントが 1,000 を超えるフェーズに入るまでは気にしなくてよく、超えても上限緩和で対応できます。App Client 分離は現実的な選択肢かなと思います。

権限がないと Forbidden ページへ(やったこと③)

App Client を分けても、フロント部分のテナント分離はまだ不十分です。

同一 User Pool 内では Hosted UI のセッション Cookie が共有されます。

tenant-a でログイン済みのユーザーが tenant-b の Hosted UI に来ると、Cookie が効いて再入力なしでトークンが発行されます。

API は Authorizer が 403 で弾きますが、画面遷移まで塞ぐには、フロント側でも tenantId を突合する必要があります。これを確実に潰すのが、ログイン後の tenantId 一致チェックです。

なお、ここで突合する ID トークンの custom:tenantId は、pre-token-generation トリガーが発行時に注入したものです。ユーザーが後から書き換えて別テナントを名乗れないよう、この属性は mutable: false(不変)で定義しています。

判定は packages/web/src/lib/auth.ts の getAuthStatus で行います。

認証状態を 3 値(authenticated / unauthenticated / wrong-tenant)で返すようにしました。

トークンの custom:tenantId と、やったこと①で解決した現在ホストのテナント(getCurrentTenantId())を突合しています。

packages/web/src/lib/auth.ts
export type AuthStatus = 'authenticated' | 'unauthenticated' | 'wrong-tenant';

export async function getAuthStatus(): Promise<AuthStatus> {
  try {
    await getCurrentUser();
  } catch {
    return 'unauthenticated';
  }

  const session = await fetchAuthSession();
  const tokenTenantId = session.tokens?.idToken?.payload?.[TENANT_CLAIM];
  const currentTenantId = getCurrentTenantId();

  if (
    !currentTenantId ||
    typeof tokenTenantId !== 'string' ||
    tokenTenantId !== currentTenantId
  ) {
    return 'wrong-tenant';
  }

  return 'authenticated';
}

wrong-tenant の場合は signIn に行かせず、/forbidden へ逃がします。

packages/web/src/router.tsx
beforeLoad: async () => {
  const status = await getAuthStatus();
  if (status === 'unauthenticated') {
    await signIn();
  } else if (status === 'wrong-tenant') {
    throw redirect({ to: '/forbidden' });
  }
},

/forbidden の ForbiddenPage は認証ガードを通さない(誰でも表示できる)ルートにしてあります。

今開いているサイトのテナント名、そしてサインアウトボタンを出しています。サインアウトは Cognito の /logout へ遷移して Hosted UI の Cookie を消すので、正しいテナントのアカウントでログインし直せます。

CleanShot 2026-09-27 at 10.03.41.png

動かしてみる

正常系と別テナントの 2 パターンを確認します。

正しいテナントでログイン

tenant-a に所属するユーザー(custom:tenantId=tenant-a)で tenant-a.example.com を開きます。起動時に /tenant-config.json が tenant-a の clientId を返します。

CleanShot 2026-09-27 at 10.04.24.png

Hosted UI でログインすると、トークンの custom:tenantId と現在ホストが一致するのでダッシュボードが表示されます。/api/me も前作どおり 200 が返ります。

CleanShot 2026-09-27 at 10.04.43.png

別テナントのサイトに入ろうとする

同じ tenant-a のユーザーで、今度は tenant-b.example.com を開きます。

Hosted UI の Cookie が共有されているため、再入力なしでトークン自体は得られてしまいます。ですが、トークンの custom:tenantId は tenant-a のまま。フロントの一致チェックで wrong-tenant と判定され、/forbidden に飛ばされて画面には入れません。

まとめ

前作の API 編に続き、フロント/認証もサブドメイン識別の流儀でテナント分離できました。やったことを 3 つに整理すると、次のとおりです。

  • ① サブドメイン({tenant}.example.com)でテナントを識別し、起動時に自テナントの設定を実行時解決する
  • ② テナント分離を App Client 単位で行い、callback/logout をテナントのオリジンに限定。Authorizer で aud も突合して多層防御にする
  • ③ ログイン後にフロントで custom:tenantId を突合し、別テナントなら Forbidden ページへ誘導する

今回の気づきは、App Client を分けても Hosted UI を利用するとログイン状態が継続されてしまうところでした。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?