MCP サーバー 認証・テナンシ設計(v2)
概要
| 項目 | 内容 |
|---|---|
| ステータス | 🟡 設計中 |
| 親ドキュメント | 案件提案書 / アーキテクチャ全体像 |
| 関連設計 | ツール一覧 / インフラ・実装計画 |
本ドキュメントは、MCP サーバーの 認証(誰か) と 認可・テナンシ(何を操作できるか) を確定する。
1. 前提条件
1.1 既存システムの状況
- Mappy(Laravel)には OAuth 2.x 認可サーバーが存在しない(Passport / Sanctum 未稼働、env 確認済み)
- 各製品の認証は Web セッション(HTTP cookie)ベース
- users テーブルは 4 製品で同一スキーマ(同じ Laravel コードベース、
DB_DATABASE=gmacで統一)
→ MCP の OAuth 2.1 認可サーバーは 完全新規構築。既存 Laravel のセッション認証とは独立に動作する。
1.2 MCP プロトコル仕様の制約
MCP 2025-03-26 仕様で認証に関する必須事項:
| 項目 | 必須 / 任意 |
|---|---|
| OAuth 2.1 | 必須(OAuth 2.0 は MCP 仕様上も非推奨) |
PKCE(code_challenge_method=S256) | 必須 |
| Implicit Flow | 廃止(使用不可) |
| Resource Owner Password Credentials Grant | 廃止(使用不可) |
| Dynamic Client Registration(RFC 7591) | 推奨(ChatGPT Connectors 互換のため) |
OAuth Metadata(RFC 8414, /.well-known/oauth-authorization-server) | 必須 |
2. OAuth 2.1 フロー詳細
2.1 認可コード + PKCE フロー(メイン)
2.2 トークン更新フロー(refresh_token rotation)
family_id による盗難検知
- 親トークンが既に rotate 済みなのに使われた → トークン漏洩の可能性
- 該当
family_idの 全トークンを失効 - ユーザーに通知 + アラート
OAuth 2.1 ベストプラクティスの中核。
2.3 動的クライアント登録(RFC 7591)
ChatGPT Connectors は事前登録不要で動的にクライアント登録するため対応必須。
公開クライアント(Claude Desktop / ChatGPT)は client_secret を保持しない ため token_endpoint_auth_method=none を許可し、PKCE で認証する。
3. エンドポイント仕様
| パス | メソッド | 認証 | 用途 |
|---|---|---|---|
/.well-known/oauth-authorization-server | GET | 不要 | OAuth メタデータ |
/oauth/register | POST | 不要 | 動的クライアント登録 |
/oauth/authorize | GET | ログイン後 | 認可エンドポイント |
/oauth/login | POST | 不要 | ログイン処理(users テーブル照合) |
/oauth/authorize/consent | POST | ログイン中 | scope 同意処理 |
/oauth/token | POST | client_id | トークン発行・更新 |
/oauth/revoke | POST | client_id | トークン失効 |
3.1 OAuth メタデータ例
{
"issuer": "https://mcp.h2t-products.com",
"authorization_endpoint": "https://mcp.h2t-products.com/oauth/authorize",
"token_endpoint": "https://mcp.h2t-products.com/oauth/token",
"revocation_endpoint": "https://mcp.h2t-products.com/oauth/revoke",
"registration_endpoint": "https://mcp.h2t-products.com/oauth/register",
"scopes_supported": [
"mcp:location:read",
"mcp:review:read",
"mcp:ranking:read",
"mcp:insight:read"
],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_basic"]
}4. scope 設計
4.1 命名規則
mcp:<domain>:<action>| 部分 | 例 |
|---|---|
mcp | 固定プレフィックス |
<domain> | location / review / ranking / insight |
<action> | read / write(Phase 2+) |
4.2 Phase 1 のスコープ一覧
| scope | 対応 tool | 必要な Mappy 側条件(実カラム) |
|---|---|---|
mcp:location:read | location.list | mappy_users.is_enabled = 1 |
mcp:review:read | review.list | mappy_users.is_enabled = 1 |
mcp:ranking:read | ranking.list | mappy_users.is_enabled = 1 AND search_ranking_enabled = 1 |
mcp:insight:read | insight.summary | mappy_users.is_enabled = 1 |
二重制御の意図
scope = MCP 経由で許可された操作種別、Mappy 側カラム = ユーザー固有の機能 ON/OFF。顧客 AI が mcp:ranking:read を持っていても、対象ユーザーの順位閲覧フラグが OFF なら **拒否(403)**する。
4.3 デフォルト scope セット
ユーザーが OAuth 同意画面で scope を細かく選ばなくても済むよう、デフォルトの「読取セット」を用意:
mcp:read=mcp:location:read mcp:review:read mcp:ranking:read mcp:insight:read
これにより、初心者ユーザーでも「全部読取可」を 1 クリックで同意できる。Phase 2 で書込が加わったら mcp:write も提供。
5. ユーザー基盤の統合方針
5.1 4 製品の users テーブル
4 製品は同一 Laravel コードベースを使っているため mappy_users テーブルのスキーマは同一(GMAC Aurora で実調査済み)。ただし データは製品別に独立(GMAC の mappy_users と GCOR の mappy_users は別レコード)。
GMAC Aurora (gmac DB)
├ mappy_users (1,442 rows)
│ ├ id, login_id, password, email, ...
│ ├ user_type (1=MAIN_USER, 2=GROUP_USER, 3=MASTER_USER)
│ ├ parent_user_id (階層構造)
│ ├ is_enabled, search_ranking_enabled
│ ├ search_ranking_access_level
│ ├ gbp_connection_settings_access_level
│ ├ anti_tamper_screen_access_level
│ ├ is_smart_meo, show_kuchikomi_settings_link
│ └ (GMAC を契約している顧客のみ)
GCOR Aurora (gmac DB)
├ mappy_users (同じスキーマ、GCOR 顧客のみ)
...同じ顧客(同じメールアドレス)が複数製品を使う場合、それぞれの製品の mappy_users に別レコードが存在する可能性がある。
認証時のメインキーは login_id(メールアドレスではない)。email カラムはあるが、ログイン用 ID は別管理。
5.2 ログイン時の product 判定方式
方式 A(採用): クライアント側が product を指定
OAuth 認可リクエスト時にクライアントが product クエリパラメータを付ける:
GET /oauth/authorize
?response_type=code
&client_id=...
&product=gmac ← クライアントが指定
&redirect_uri=...
&code_challenge=...→ MCP サーバーはそのプロダクトの Aurora の mappy_users テーブルで認証する。
利点:
- 顧客 AI 側(Claude Desktop の MCP 設定など)で 1 回設定すれば以後固定
- 1 顧客 = 1 製品 = 1 OAuth クライアントになり、シンプル
- 認証 Aurora の振り分けが認可開始時点で決まるので実装単純
方式 B(不採用): メールアドレスで全製品の users を横断検索
ログイン画面でメール入力 → 4 製品全 users テーブルを順次検索 → ヒットした製品で認証。
不採用理由:
- 同じメールアドレスが複数製品に存在する場合、どちらを選ぶかユーザーに聞く UI が必要
- DB 接続が 4 倍になり、遅い
- 認証エラー時のレスポンス時間が長い(4 製品全部見てから「該当なし」を返す)
→ Phase 1 は方式 A で確定。将来「1 接続で複数製品横断」要件が出たら Phase 3 以降で再検討。
5.3 認証時のフロー
6. テナンシ判定アルゴリズム
6.1 用語
| 用語 | 意味 |
|---|---|
| 認証ユーザー | OAuth トークンの主体(JWT の sub クレームの user_id) |
| 対象店舗 | tool で操作対象とする gbp_locations.id |
6.2 店舗境界の判定(Phase 1)
visible_locations(user_id) =
SELECT gbp_location_id
FROM mappy_user_available_gbp_locations
WHERE user_id = :user_id→ Phase 1 では「ユーザーが直接見られる店舗」のみで判定(mappy_user_available_gbp_locations の単純照合)。mappy_groups / mappy_group_location 経由の集約は Phase 2 以降。
→ tool の入力で location_id が指定された場合、上記の集合に含まれているか検証。含まれていなければ FORBIDDEN_LOCATION で拒否。
6.3 機能フラグチェック
scope だけでなく、対象ユーザーのアプリケーション側フラグも確認する(実カラム名は本番調査で確定):
| scope | 必要なフラグ |
|---|---|
mcp:location:read | mappy_users.is_enabled = 1 |
mcp:review:read | mappy_users.is_enabled = 1 |
mcp:ranking:read | mappy_users.is_enabled = 1 AND mappy_users.search_ranking_enabled = 1 |
mcp:insight:read | mappy_users.is_enabled = 1 |
トークンに該当 scope があっても、ユーザーのフラグが OFF なら拒否(403 FORBIDDEN_FEATURE)。
Phase 2 以降での書込系 scope に対応する access_level カラム(将来用、Phase 1 では使わない):
| 将来 scope | 必要なフラグ |
|---|---|
mcp:location:write(将来) | mappy_users.gbp_connection_settings_access_level >= 2 |
mcp:antitamper:read(将来) | mappy_users.anti_tamper_screen_access_level >= 1 |
mcp:smartmeo:*(将来) | mappy_users.is_smart_meo = 1 |
mcp:kuchikomi:settings(将来) | mappy_users.show_kuchikomi_settings_link = 1 |
6.4 判定関数の擬似コード
def authorize_request(token: AccessToken, tool_name: str, input: dict) -> ResolvedContext:
# 1. tool 必須 scope のチェック
required_scope = TOOL_SCOPE_MAP[tool_name]
if required_scope not in token.scopes:
raise Forbidden(f"missing scope: {required_scope}")
# 2. ユーザー情報取得 (対象 product の Aurora の mappy_users から)
# SELECT id, user_type, parent_user_id, is_enabled, search_ranking_enabled,
# search_ranking_access_level, gbp_connection_settings_access_level,
# anti_tamper_screen_access_level, is_smart_meo
# FROM mappy_users WHERE id = :user_id
user = fetch_mappy_user(token.product, token.user_id)
if not user.is_enabled:
raise Forbidden("user disabled")
# 3. 機能フラグチェック
if required_scope == 'mcp:ranking:read' and not user.search_ranking_enabled:
raise Forbidden("ranking feature disabled for this user")
# 4. 店舗境界チェック
if 'location_id' in input:
if input['location_id'] not in visible_locations(token.product, token.user_id):
raise Forbidden("location not accessible")
return ResolvedContext(
product=token.product,
user_id=token.user_id,
user=user,
)7. トークンライフサイクル
| トークン | 形式 | 有効期限 | rotate | 保存先 |
|---|---|---|---|---|
authorization_code | Opaque(32 文字ランダム) | 60 秒 | 1 回使用で消費 | DynamoDB(TTL) |
access_token | JWT (RS256) | 60 分 | リフレッシュで新発行 | クライアント側のみ(サーバーは jti 失効リスト管理) |
refresh_token | Opaque(64 文字ランダム) | 30 日(rolling) | リフレッシュで rotate(旧失効) | DynamoDB |
id_token(将来) | JWT | — | — | (Phase 1 では未実装) |
7.1 JWT クレーム設計
{
"iss": "https://mcp.h2t-products.com",
"sub": "1435",
"aud": "mcp",
"exp": 1735689600,
"iat": 1735686000,
"jti": "01HVABCDEFG...",
"scope": "mcp:location:read mcp:review:read",
"product": "gmac",
"user_type": 1,
"client_id": "01HV..."
}| クレーム | 意味 |
|---|---|
sub | mappy_users.id |
aud | "mcp" 固定 |
exp | 発行から 60 分後 |
iat | 発行時刻 |
jti | JWT 一意 ID(失効リスト用) |
scope | スペース区切りの scope 列 |
product | "gmac" / "gcor" / "pipit" / "kuchikomi_one" |
user_type | mappy_users.user_type(1=MAIN_USER, 2=GROUP_USER, 3=MASTER_USER) |
client_id | OAuth クライアント ID |
7.2 失効戦略
| 事象 | 動作 |
|---|---|
| ユーザーがログアウト・連携解除 | 該当 sub の access_token jti を失効リストに追加、refresh_token を revoked_at セット |
| 該当製品の users.is_enabled = 0 になった | 同上、ただし機能フラグチェックで実質的に拒否されるので即時失効は不要 |
/oauth/revoke 呼び出し | 該当トークンを失効 |
| refresh_token 再使用検出 | family_id 配下を一括失効 + ユーザー通知 |
| パスワード変更 | refresh_token 全失効 |
7.3 JWT 署名鍵
| 項目 | 内容 |
|---|---|
| アルゴリズム | RS256(推奨) |
| 鍵長 | 2048bit 以上 |
| ローテーション周期 | 90 日 |
| 保管 | AWS Secrets Manager(mcp/jwt-signing-key) |
kid(key id) | JWT ヘッダーに含め、複数鍵を並行運用可能 |
| 公開鍵公開 | /.well-known/jwks.json で配布 |
8. セキュリティ要件
8.1 必須対応
| 項目 | 対応 |
|---|---|
| PKCE | code_challenge_method=S256 必須、plain は禁止 |
| state | CSRF 対策、必須 |
| redirect_uri | 完全一致(部分マッチ禁止) |
| code 再使用 | 1 回で消費、再使用検知時は関連トークン全失効 |
| refresh_token rotate | 必須、family_id で再使用検出 |
| HTTPS 必須 | HTTP 受付不可、HSTS 有効 |
| クッキー(同意画面用) | Secure, HttpOnly, SameSite=Lax |
8.2 redirect_uri ホワイトリスト
公式 MCP クライアントの redirect_uri を OAuth クライアント登録時に検証:
| クライアント | redirect_uri パターン |
|---|---|
| Claude Desktop | https://claude.ai/api/mcp/auth_callback |
| Claude Code | http://localhost:*/callback(動的ポート) |
| ChatGPT Connectors | https://chat.openai.com/oauth/callback(要検証) |
localhost の動的ポートは Claude Code 等で必要なため、明示的に opt-in したクライアントのみ許可。
8.3 セキュリティヘッダー
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'8.4 ログ・監査
- password / token / code 本体はログ出力禁止
- ログには
jti(JWT ID)のみ記録 - 認可失敗・トークン拒否は CloudWatch Logs + DynamoDB 監査ログに記録
- メトリクスとして「認証失敗率」「refresh 再使用検出回数」を CloudWatch で監視
9. DynamoDB スキーマ
OAuth 認可サーバーで必要な永続データを DynamoDB に格納する。VPC 不要、IAM 経由でアクセス。
9.1 テーブル一覧
| テーブル名 | 主キー | TTL | 用途 |
|---|---|---|---|
mcp_oauth_clients | client_id (PK) | なし | OAuth クライアント情報 |
mcp_oauth_codes | code_hash (PK) | expires_at(60s) | 認可コード |
mcp_oauth_refresh_tokens | token_hash (PK), user_id (GSI) | expires_at(30d) | リフレッシュトークン |
mcp_oauth_consents | user_id (PK), client_id (SK) | なし | ユーザー同意 |
mcp_jwt_revocations | jti (PK) | expires_at(≦ 60 min) | アクセストークン失効リスト |
mcp_audit_logs | request_id (PK), user_id+created_at (GSI) | 1 年 | 監査ログ |
9.2 主要テーブルの属性
mcp_oauth_clients
| 属性 | 型 | 説明 |
|---|---|---|
client_id | String (PK) | UUID v4 |
client_name | String | "Claude Desktop" 等 |
redirect_uris | List | 許可された callback URL の配列 |
grant_types | List | ["authorization_code", "refresh_token"] |
scopes_allowed | List | このクライアントが要求可能な scope |
token_endpoint_auth_method | String | "none" / "client_secret_basic" |
client_type | String | "public" / "confidential" |
registration_method | String | "manual" / "dynamic" |
is_enabled | Bool | 有効/無効 |
created_at / updated_at | String (ISO) |
mcp_oauth_refresh_tokens
| 属性 | 型 | 説明 |
|---|---|---|
token_hash | String (PK) | SHA-256(token) |
user_id | Number (GSI PK) | users.id |
product | String | "gmac" 等 |
client_id | String | |
scope | String | スペース区切り |
family_id | String | rotate チェーン識別子 |
parent_token_hash | String | 直前トークンの hash |
issued_at | String (ISO) | |
expires_at | Number (Unix epoch) | TTL 用 |
revoked_at | String (ISO) | nullable |
revoke_reason | String | rotation / user_revoke / family_compromise 等 |
mcp_oauth_codes
| 属性 | 型 | 説明 |
|---|---|---|
code_hash | String (PK) | SHA-256(code) |
client_id | String | |
user_id | Number | |
product | String | |
scope | String | |
redirect_uri | String | |
code_challenge | String | PKCE |
code_challenge_method | String | "S256" |
expires_at | Number (Unix epoch) | TTL(60s) |
consumed_at | String (ISO) | 消費時刻 |
9.3 オンデマンドキャパシティで運用
DynamoDB は on-demand 課金 とし、書込/読込量に応じた自動スケール。初期の低トラフィック時にコスト最小化。
10. レビュー時の論点(未確定)
| # | 論点 | 候補 |
|---|---|---|
| 1 | access_token 有効期限 | 60 分(標準)/ 30 分(厳しめ) |
| 2 | refresh_token rolling 期間 | 30 日 / 14 日 |
| 3 | JWT 署名アルゴリズム | RS256(推奨)/ HS256 |
| 4 | ログイン画面の UI / 製品ブランディング | 1 画面共通か、製品ごとにテーマか |
| 5 | scope 同意画面の粒度 | 個別 scope / グループ単位(mcp:read ひとまとめ) |
| 6 | 既存 Mappy ログイン情報との同期 | パスワード変更時の MCP 失効連携 |
| 7 | 動的クライアント登録の制限 | 全許可 / 1 ユーザーあたり同時 N 個まで |
| 8 | カスタムドメインの確定 | mcp.h2t-products.com 等、未決定 |
11. 関連ドキュメント
- 案件提案書
- アーキテクチャ全体像 — 認証コンポーネントの全体配置
- ツール一覧 — scope と tool の対応
- インフラ・実装計画 — DynamoDB / Secrets Manager の AWS リソース定義