tenant-pass

tenant-pass-gateway

すぐに使える forward-auth 方式のゲートウェイです。ID プロバイダーの JWT を検証し、ユーザーが属するテナントのパスにだけ署名します。判定の一覧、ルート、設定、Caddy・nginx・Traefik・Envoy の設定例、セッションをテナントごとに分ける方法を説明します。

このページの見出し
  1. 判定
  2. セッションのトークン
  3. ルート
  4. 設定
  5. プロキシの設定
  6. セッションのトークンをアプリケーションに渡さない
  7. テナントのホストをまたぐ Cookie のセッション
  8. 残るリスク
  9. デプロイ

tenant-pass-gateway は、tenant-pass に付属するゲートウェイです。リバースプロキシの forward-auth(転送の前に別のサービスへ許可を問い合わせる仕組み)の問い合わせ先として置くだけで動きます。パスに署名するのは、エンドユーザーが、要求されたホストのテナントの有効なセッションを持っているときだけです。既存の部品でパスを発行したい場合は、ゲートウェイ連携(独自のゲートウェイ) の決まりに従って自作してください。

リクエストは次のように流れます。

  1. ブラウザーがリバースプロキシ(Caddy、nginx、Traefik、Envoy)にリクエストを送ります。
  2. プロキシは、クライアントが送った Tenant-Pass ヘッダーを取り除きます。
  3. プロキシは、リクエストのヘッダーだけを tenant-pass-gateway に送って問い合わせます。
  4. ゲートウェイは、ホスト名からルートを探して、転送先のアプリケーションとテナントを決めます。次に、セッションの JWT を ID プロバイダーの JWKS(公開鍵の一覧)で検証し、そのテナントがトークンのテナントクレームに含まれているかを確かめます。
  5. ゲートウェイは、200 とパスの入った Tenant-Pass ヘッダーを返します(パスなしの 200 か、403 を返すこともあります)。
  6. プロキシは Tenant-Pass をリクエストにコピーして、アプリケーションに転送します。
  7. アプリケーションは、受け取ったパスを tenant-pass に渡して認証情報と交換します。

この流れの中で、アプリケーションは一度も信頼されていません。パスを受け取ることはできても、作ることはできないからです。

判定

状況レスポンスパス
有効なセッションがあり、ホストのテナントがテナントクレームに含まれる200あり
有効なセッションがあるが、別のテナントのもの403なし(プロキシがリクエストを拒否します)
セッションがない、無効、または期限切れ200(既定の pass_through の場合)または 401(deny の場合)なし
ホストがどのルートにも一致しない200なし
Host ヘッダーの形式が正しくない、または 1 つに決まらない400なし
caller_secret_file を設定していて、ヘッダーがないか値が違う401なし

pass_through にしておくと、セッションのないリクエストもアプリケーションまで届くので、アプリケーションはログインページを出せます。パスが付いていない以上テナントの認証情報は取れないので、通しても困ることはありません。

セッションのトークン

ゲートウェイが検証するのは、利用者の ID プロバイダーが発行した JWT です。OIDC の ID トークンでも、アクセストークンでも、自社のログインサービスが発行するセッションの JWT でもかまいません。

トークンは、authorization_header が true なら、まず Authorization: Bearer ヘッダーから探します。見つからなければ、identity.cookie で指定した名前の Cookie から読みます。

署名は ID プロバイダーの JWKS(HTTPS で取得する jwks_url、またはファイルの jwks_file)で検証し、鍵は kid で選びます。認めるのは非対称の署名方式だけで、既定は RS256、ES256、EdDSA です。HS* と none はどう設定しても受け付けません。JWK に alg が書かれていれば、トークンもその方式で署名されていなければなりません。

クレームは次のように確かめます。iss は identity.issuer と一致し、aud には identity.audiences のどれかが含まれていなければなりません。exp は必須で、nbf と iat は入っていれば確かめます。max_token_age を設定すれば、古すぎるトークンも拒否できます。

テナントクレーム(identity.tenant_claim)は、文字列か文字列の配列です。ドットで区切れば入れ子のクレームも指定できます。

ほかに、任意の設定が 2 つあります。identity.forward_claims は、パスと一緒にヘッダーで返すクレームで、アプリケーションがトークンそのものを見ずに済むようにするためのものです(後で説明します)。identity.required_claims には、値が完全に一致しなければならない文字列のクレームを書きます({"azp": "web-app"} や {"typ": "Bearer"} など)。オーディエンスがたまたま重なっているだけの、別のクライアント向けや別の目的のトークンを通さないためです。アクセストークンを待っているところに ID トークンが来る、といった取り違えがこれで防げます。

JWKS のうち使うのは、kid を持つ署名用の公開鍵だけです。kid が重複している鍵は捨てます。

URL から取得する場合は、バックグラウンドで 10 分ごとに取り直します。知らない kid のトークンが来たときにも取り直しますが、これは 30 秒に 1 回までです。取り直しの途中できっかけになったクライアントが接続を切っても、処理は中断しません。

1 時間のあいだ一度も取得に成功しなければ、ゲートウェイは手元の古い鍵を信頼するのをやめ、すべてのトークンを拒否します。取得できない間に ID プロバイダーが無効にした鍵を、いつまでも信頼し続けないようにするためです。

ルート

ルートは、ホスト名から転送先のアプリケーションとテナントを決める設定です。

"routes": [
  {"host": "{tenant}.billing.example.com", "app": "billing"},
  {"host": "shop.acme-corp.com",           "app": "billing", "tenant": "acme"},
  {"host": "app.example.com",              "app": "crm",     "tenant_from_token": true}
]
書き方テナントの決め方確認すること
ラベル(ドットで区切られた 1 つの部分)全体を {tenant} にするホスト名のそのラベルラベルがトークンのテナントに含まれる
ホスト名をそのまま書き、tenant を指定する設定の値(独自ドメインの場合)そのテナントがトークンのテナントに含まれる
ホスト名をそのまま書き、tenant_from_token を指定するトークントークンに書かれたテナントがちょうど 1 つである

ホスト名をそのまま書いたルートは、パターンのルートより優先されます。{tenant} はちょうど 1 つのラベルに一致します。たとえば x.acme.billing.example.com は {tenant}.billing.example.com に一致しません。

ホスト名は、request.host_header で指定したヘッダーから読みます(既定は X-Forwarded-Host。Envoy の ext_authz では Host)。小文字にし、ポートと末尾のドットを取り除いた結果が、正規化済みのホスト名になっていなければなりません。ASCII 以外の文字を含む値は、小文字にする前に拒否します。Unicode の大文字・小文字の変換で ASCII の英字に化ける文字があるからです。ホスト名を並べた値(a, b)や、同じヘッダーが複数行あるときも拒否します。プロキシは、クライアントが送ったヘッダーに値を足すのではなく、値を置き換えるように設定してください。

ゲートウェイが作るパスには、必ずテナント ID(tenant クレーム)が入ります。aud はルートの app です。

設定

{
  "listen": "127.0.0.1:9180",
  "insecure_plaintext_http": true,
  "caller_secret_file": "/run/secrets/gateway-caller-secret",

  "signing_key": {"id": "gw-2026-11", "private_key_file": "/run/secrets/gateway.key"},

  "identity": {
    "issuer": "https://login.example.com/",
    "audiences": ["tenant-apps"],
    "jwks_url": "https://login.example.com/.well-known/jwks.json",
    "algorithms": ["RS256", "ES256", "EdDSA"],
    "authorization_header": true,
    "cookie": "session",
    "tenant_claim": "tenant_id",
    "leeway": "30s",
    "cookie_fetch_metadata": "enforce"
  },
  "routes": [{"host": "{tenant}.billing.example.com", "app": "billing"}],
  "unauthenticated": "pass_through",
  "pass": {"ttl": "30s", "header": "Tenant-Pass"},
  "request": {"host_header": "X-Forwarded-Host"},
  "log_level": "info"
}
キー既定値説明
listen127.0.0.1:9180待ち受けるアドレスです。
tlscert_file と key_file を指定します。client_ca_file を設定するとクライアント証明書が 必須 になり、その場合は client_uris と client_dns_names の片方または両方で、プロキシの証明書を指定する必要があります。その CA が発行した証明書でも、SAN(証明書に書かれた名前)のどれかが一致するものしか受け付けません(サービスメッシュでは、アプリケーションを含むすべてのワークロードが同じ CA の証明書を持っているためです)。
max_concurrent_requests256同時に処理する問い合わせの上限です。超えた分には 503 を返します。
identity.leeway30sexp・nbf・iat を確かめるときに許す時計のずれです(0〜5 分。"0s" でずれを許しません)。
identity.single_tenant_tokensfalseテナントクレームがちょうど 1 つのテナントを指していることを求めます(「テナントのホストをまたぐ Cookie のセッション」をご覧ください)。
identity.cookie_fetch_metadataenforceCookie から読んだトークンに Fetch Metadata の確認をするかどうかです。enforce か off を指定します。
allow_insecure_plaintextfalseテスト専用です。ループバック以外のアドレスで暗号化しない設定を使うことを許します。
log_levelinfodebug、info、warn、error のどれかです。
health_listenGET /healthz を受けるアドレスです(任意)。forward-auth のリスナーは、どのパスへのリクエストも問い合わせとして扱うので、ヘルスチェック用のパスを持てません。
insecure_plaintext_httpfalse暗号化しない HTTP を使います。ループバックのアドレス(プロキシの隣で動かすサイドカー)なら使えます。それ以外のアドレスでは allow_insecure_plaintext も必要です(テスト専用)。
caller_secret_file設定すると、呼び出し元はこのファイルの値を Tenant-Pass-Gateway-Secret ヘッダーで送る必要があります(16 文字以上)。listen がループバック以外のアドレスなら、caller_secret_file か tls.client_ca_file のどちらかが 必須 です。
kms.vaultVault / OpenBao への接続の設定です。鍵管理(Vault / OpenBao Transit) をご覧ください。
signing_key必須id(パスの kid になります)と、private_key_file(PEM)か transit(key、version)のどちらかを指定します。
identity.*上の説明をご覧ください。jwks_tls(ca_file など)は、社内の ID プロバイダーが独自の CA を使っている場合に指定します。max_token_age を指定すると、iat がそれより古いトークンを拒否します。
routes必須上の説明をご覧ください。
unauthenticatedpass_throughセッションのないリクエストの扱いです。pass_through か deny を指定します。
pass.ttl30sパスの有効期間です(5 秒〜5 分)。tenant-pass の pass.max_ttl より長くしてはいけません。
pass.headerTenant-Passパスを入れるレスポンスヘッダーの名前です。
request.host_headerX-Forwarded-Host元のホスト名が入っているヘッダーの名前です。

コマンドは tenant-pass-gateway serve -config FILE と tenant-pass-gateway check-config -config FILE です。

tenant-pass の側には、ゲートウェイの公開鍵を同じ id で gateway_keys に追加します。鍵ペアは tenant-pass gen-gateway-key で作れます。Transit の鍵を使うときの公開鍵の取り出し方は、鍵管理(Vault / OpenBao Transit) にあります。

プロキシの設定

どのプロキシでも、守るべき順番は同じです。ゲートウェイに問い合わせる前に、クライアントが送ってきた Tenant-Pass ヘッダーをすべて取り除き、そのあとでゲートウェイが返したヘッダーをコピーします。これを怠ると、セッションを持たないクライアントが、どこかで手に入れたパスをそのままアプリケーションに届けられてしまいます。

Caddy(動作確認済み)

*.billing.example.com {
	# route{} の中は書いた順に実行される。Caddy の既定の順序では
	# forward_auth が request_header より先に動くため、
	# ゲートウェイが追加したばかりのパスまで消えてしまう。
	route {
		request_header -Tenant-Pass
		forward_auth 127.0.0.1:9180 {
			uri /
			header_up Tenant-Pass-Gateway-Secret {env.TP_GATEWAY_SECRET}
			copy_headers Tenant-Pass
		}
		reverse_proxy billing:8080
	}
}

nginx(動作確認済み)

location = /_tenant_pass_auth {
    internal;
    proxy_pass http://127.0.0.1:9180/;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Uri $request_uri;
    proxy_set_header Authorization $http_authorization;
    proxy_set_header Cookie $http_cookie;
    include /etc/nginx/tenant-pass-secret.conf;   # 中身は proxy_set_header Tenant-Pass-Gateway-Secret "..."; (権限 0600)
}

location / {
    auth_request /_tenant_pass_auth;
    auth_request_set $tenant_pass $upstream_http_tenant_pass;
    # 必ず設定する。値が空なら、クライアントが送ったヘッダーが消える。
    proxy_set_header Tenant-Pass $tenant_pass;
    proxy_set_header X-Forwarded-Host $host;   # クライアントが送った値をそのまま渡さない
    proxy_pass http://billing:8080;
}

Traefik

Traefik の ForwardAuth には、ゲートウェイへの問い合わせにだけヘッダーを足す方法がありません。headers ミドルウェアで足すと、アプリケーションにも同じヘッダーが届いてしまいます。そのため Traefik では caller_secret_file を安全に使えず、ループバック以外のアドレスでは mTLS を使うことになります。Traefik 側ではクライアント証明書を設定した forwardAuth.tls を、ゲートウェイ側では client_ca_file と client_uris / client_dns_names を設定してください。

http:
  middlewares:
    strip-pass:
      headers:
        customRequestHeaders:
          Tenant-Pass: ""          # クライアントが送ったヘッダーを消す
          Tenant-Pass-User: ""     # forward_claims のヘッダーもすべて消す
    tenant-pass:
      forwardAuth:
        address: http://127.0.0.1:9180/
        authResponseHeaders: ["Tenant-Pass", "Tenant-Pass-User"]
        authRequestHeaders: ["Authorization", "Cookie", "X-Forwarded-Host", "Sec-Fetch-Site", "Sec-Fetch-Mode", "Sec-Fetch-User"]
  routers:
    billing:
      rule: HostRegexp(`^[a-z0-9-]+\.billing\.example\.com$`)
      middlewares: [strip-pass, tenant-pass]
      service: billing

Envoy(ext_authz、HTTP)

request.host_header を Host にしてください。ext_authz フィルターでは、allowed_headers に authorization、cookie、sec-fetch-site、sec-fetch-mode、sec-fetch-user を指定し、allowed_upstream_headers に tenant-pass(と、forward_claims のすべてのヘッダー)を指定してください。

クライアントが送った tenant-pass と、ユーザー ID を伝えるヘッダーは、ext_authz フィルターが動く前に取り除きます。HTTP フィルターの並びで、ext_authz より前に header-mutation フィルターを置いてください。ルートの request_headers_to_remove は使えません。ext_authz の後で効くので、ゲートウェイが付けたパスまで消えてしまいます。

コネクションマネージャーには headers_with_underscores_action: REJECT_REQUEST も設定します。ヘッダー名のアンダースコアをハイフンに読み替えるアプリケーションに、tenant_pass という名前で偽のパスが届くのを防ぐためです。

本番の前に確かめること

セッションを持たず、偽の Tenant-Pass ヘッダーだけを付けたリクエストを送ってみてください。アプリケーションにそのヘッダーが届かなければ、プロキシの設定は正しくできています。

アプリケーションに渡す X-Forwarded-Host は、どのプロキシでもプロキシ自身が値を設定します。クライアントが送った値をそのまま渡してはいけません。アプリケーションの側も、テナントは認証情報のレスポンスの tenant からだけ読み、ホスト名のヘッダーからは決して読まないようにしてください。

セッションのトークンをアプリケーションに渡さない

アプリケーションが乗っ取られても、セッションのトークンを使い回されないようにしたい。いちばん確実なのは、トークンを最初から渡さないことです。identity.forward_claims を設定すると、ゲートウェイはパスを返すときに、検証済みのクレームもヘッダーで返します。プロキシは、トークンの代わりにそのヘッダーをアプリケーションへ送ります。

"identity": { "...": "...", "forward_claims": {"Tenant-Pass-User": "sub"} }
route {
	request_header -Tenant-Pass
	request_header -Tenant-Pass-User
	forward_auth 127.0.0.1:9180 {
		uri /
		copy_headers Tenant-Pass Tenant-Pass-User
	}
	# アプリケーションに渡すのはユーザー ID だけ。セッションのトークンは渡さない。
	request_header -Authorization
	reverse_proxy billing:8080
}

この設定は Caddy 2 で動作を確かめています。アプリケーションに届くのはパスと Tenant-Pass-User で、Authorization は届きません。クライアントが自分で付けた Tenant-Pass-User は取り除かれます。

ユーザーの ID として転送するのは、sub のように ID プロバイダーが管理し、変わることのない識別子にしてください。email はユーザー自身が変えられるうえ、確認済みとは限らないので、ID には向きません。使うなら、required_claims で email_verified も求めてください。

転送するクレームの値は、表示できる ASCII の文字列で 256 バイト以下でなければなりません。クレームがないときやこの条件を満たさないときは、トークンを無効として扱い、パスも発行しません。ヘッダー名に Authorization、Cookie、Host などは使えません。そして Tenant-Pass と同じく、転送に使うヘッダー名はすべて、プロキシでクライアントのリクエストから取り除いておいてください。

セッションを Cookie で扱う場合は、ID プロバイダーのセッション専用の Cookie を使い、それをプロキシで取り除きます(nginx なら $http_cookie を書き換え、Caddy なら正規表現で置き換える request_header を使います)。アプリケーションは自分の Cookie をこれまでどおり使えますが、ID プロバイダーのセッションは見えなくなります。

1 つのアプリケーションが多くのテナントのホストを受け持ち、セッションの Cookie がそのすべてに送られる構成(Domain=.billing.example.com を付けた Cookie)には、注意が要ります。乗っ取られたアプリケーションは、ユーザーのブラウザーにほかのテナントのホストを開かせて、ユーザーが属するテナントの分だけパスを集められるからです。画像や iframe で埋め込む、リダイレクトで経由させる、そのページに移動させる、と手口はいくつもあり、どのリクエストもプロキシを通って同じアプリケーションに戻ってきます。

これはリクエストのヘッダーでは防げません。防ぐには、セッションの範囲を分けます。

  1. セッションの Cookie をホストごとに限ります。テナントのホストごとに、Domain 属性を付けずに発行してください。名前は、できれば __Host- で始めます(Set-Cookie: __Host-session=…; Secure; Path=/; HttpOnly; SameSite=Lax)。これでブラウザーは acme のセッションを acme.billing.example.com にしか送らなくなり、ほかのテナントのホストを開かせてもセッションは付きません。
  2. トークンを 1 つのテナントに限ります。identity.single_tenant_tokens: true を設定し、ログインサービスがテナントごとに別のトークン(テナントクレームがちょうど 1 つのテナントを指すもの)を発行するようにします。こうしておけば、ブラウザーに何をさせても、acme のトークンからほかのテナントのパスは出てきません。

複数のテナントに属するユーザーは、テナントのホストごとにログインすることになります(シングルサインオンを使ってもかまいません)。複数のテナントがアプリケーションを共有する構成では、両方とも行ってください。

Fetch Metadata による追加の防御

identity.cookie_fetch_metadata が "enforce"(既定)なら、Cookie から読んだトークンを使うのは、ブラウザーが付ける Fetch Metadata ヘッダーが次のどれかを示すときだけです。

  • 同じオリジンからのリクエスト(Sec-Fetch-Site: same-origin)
  • アドレスバーへの入力やブックマークからのアクセス(none)
  • ユーザーの操作で始まったページの移動(Sec-Fetch-Mode: navigate かつ Sec-Fetch-User: ?1)

Fetch Metadata のないリクエストには、パスを発行しません。ほかのテナントのホストを指す <img> のように、ユーザーが操作していないのにホストをまたぐリクエストは、これで止まります。

ただし、乗っ取られたアプリケーションに対しては確実な防御になりません。そのアプリケーションはほかのテナントのページも配信しているので、そのページをフレームに入れたり、そこへ移動させたりすれば、同じオリジンからのリクエストを起こせます。ユーザーの操作で始まったという扱いも、リダイレクトを経たあとまで残ります。

この確認には、プロキシが Sec-Fetch-* ヘッダーをゲートウェイに渡している必要があります。Caddy と nginx は既定で渡します。Traefik と Envoy は上の設定例のとおりに指定してください。"off" にしてよいのは、Cookie を使うクライアントがブラウザーではない場合だけです。

Authorization ヘッダーのトークンはこの確認の対象外です。ブラウザーがこのヘッダーを勝手に付けることはないからです。また、セッションの名前の Cookie が複数届いたときは、セッションがないものとして扱います。兄弟のホストから仕込まれた Cookie で、ユーザーが攻撃者のセッションにログインさせられるのを防ぐためです。

残るリスク

複数のテナントにまたがるセッション

セッションをテナントごとに分けていない(ホストごとの Cookie も single_tenant_tokens も使っていない)と、乗っ取られたアプリケーションは、ユーザーのセッションが有効な間、そのユーザーのテナントクレームにあるすべてのテナントのパスを手に入れられます。経路は 2 つで、見かけたトークンを使い回すことと、ユーザーのブラウザーにほかのテナントのホストを開かせることです。

使い回しは、forward_claims でトークンを取り除けば塞がります。アプリケーションがゲートウェイを直接呼ぶ経路は、呼び出し元の認証(ループバック以外では必須)で塞がります。Fetch Metadata は、ユーザーの操作によらないホストをまたぐリクエストを止めます。それでも、ブラウザーを使う経路を完全に塞げるのは、セッションの範囲を分けることだけです。

KMS への負荷

Transit の署名鍵を使うと、認証済みのリクエスト 1 件ごとに Vault で 1 回署名します。それに耐えられる規模の Vault を用意し、必要ならプロキシでレート制限をかけてください。

デプロイ

置き場所は 2 通りです。プロキシの隣(同じホストか同じポッド)でループバックのアドレスに置くか、内部のネットワークに置いて TLS と client_ca_file(または caller_secret_file)で守るかです。

どちらの場合も、アプリケーションからゲートウェイへは接続できないようにしておきます。接続できると、好きな X-Forwarded-Host を付けてパスを要求できてしまうからです(それでも、そのテナントの有効なユーザーのセッションは要ります)。

署名鍵はゲートウェイ(または Vault Transit)にだけ置きます。tenant-pass が持つのは公開鍵だけです。

ゲートウェイは、判定ごとに 1 行の JSON(host、app、tenant、result、reason)をログに出します。トークンやパスは出しません。

この文書は、リポジトリにある docs/ja/gateway.md をもとにしています(英語版は docs/en/gateway.md)。