CognitoのOAuth 2.0トークン交換でMCP向けにスコープを絞る利用を試してみた

CognitoのOAuth 2.0トークン交換でMCP向けにスコープを絞る利用を試してみた

Amazon Cognito の OAuth 2.0 トークン交換(GetDerivedToken)で、MCP サーバー向けにスコープを絞ったトークンを発行しました。API Gateway と AgentCore Gateway で、絞ったトークンは必要なスコープが足りず拒否されることを確認できました。
2026.11.15

はじめに

2026年11月12日に、Amazon Cognito が OAuth 2.0 トークン交換に対応しました。

https://aws.amazon.com/jp/about-aws/whats-new/2026/11/cognito-token-exchange/

トークン交換を使うと、ユーザーに代わって下流の API を呼ぶサービスは、ユーザーのトークンから、スコープを絞った新しいトークンを得られます。

この記事では、MCP サーバーに見立てた app client でこの交換を行い、交換前後のトークンのクレームを比べました。さらに、両方のトークンを、API Gateway と Lambda で作った S3 の一覧を返す API、API Gateway の認可スコープ、AgentCore Gateway に渡して結果を比べました。

検証の準備

共通のリソース

ユーザープールは ap-northeast-1 の Essentials プランです。リソースサーバー files に public.read と private.read の2つのスコープを定義しました。S3 バケットには private/ と public/ に2件ずつオブジェクトを置いています。

ユーザーのサインインには USER_PASSWORD_AUTH を使いました。ブラウザ操作を省いて files のスコープ付きのトークンを得るため、サインイン時に Pre token generation Lambda でアクセストークンへスコープを追加しています。この Lambda が行うのはスコープの追加だけで、トークン交換は Cognito が行います。

app client の設定

交換を依頼する app client(mcp-server)は、ExplicitAuthFlows に ALLOW_TOKEN_EXCHANGE_DELEGATION と ALLOW_TOKEN_EXCHANGE_IMPERSONATION を指定します。この app client は、次のコマンドで作成します。

aws cognito-idp create-user-pool-client --user-pool-id <user-pool-id> --client-name mcp-server --generate-secret --explicit-auth-flows ALLOW_TOKEN_EXCHANGE_DELEGATION ALLOW_TOKEN_EXCHANGE_IMPERSONATION ALLOW_REFRESH_TOKEN_AUTH --allowed-o-auth-flows code --allowed-o-auth-flows-user-pool-client --allowed-o-auth-scopes files/public.read files/private.read --callback-urls https://localhost/callback --supported-identity-providers COGNITO

アクタートークンには、GetClientToken で取得した M2M トークンを使いました。GetClientToken を使う app client は認証フローを ALLOW_CLIENT_TOKEN_AUTH だけにする必要があります。mcp-server には交換用のフローを付けているため、別に mcp-actor-m2m を作成しました。

アクタートークン用 app client の作成とトークンの取得
aws cognito-idp create-user-pool-client --user-pool-id <user-pool-id> --client-name mcp-actor-m2m --generate-secret --explicit-auth-flows ALLOW_CLIENT_TOKEN_AUTH --allowed-o-auth-flows client_credentials --allowed-o-auth-flows-user-pool-client --allowed-o-auth-scopes files/public.read

トークンの取得には、この app client の ClientId と Secret を入れた JSON ファイルを渡します。GetClientToken では、シークレットのキー名が ClientSecret ではなく Secret です。トークンは応答の ClientAuthenticationResult の下に入ります。次に、コマンドとその中身を示します。

aws cognito-idp get-client-token --cli-input-json file:///tmp/actor-req.json
{
  "AccessToken": "<AccessToken: 862 chars>",
  "ExpiresIn": 3600,
  "TokenType": "Bearer"
}

GetDerivedToken の呼び出し方

記事執筆時点の CLI(aws-cli/2.40.3)と SDK(boto3/1.44.3)では get-derived-token を利用できなかったため、API を直接呼びました。署名は不要で、リクエストボディに ClientId と ClientSecret を入れると呼べます。パラメータの一覧は API Reference に記載されています。

https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_GetDerivedToken.html

SubjectToken にユーザーのアクセストークンを、Scopes に files/public.read を指定しました。アクタートークンは付けていません。

request: {"ClientId": "<mcp-server-client-id>", "ClientSecret": "<ClientSecret: 51 chars>", "SubjectToken": "<SubjectToken: 1089 chars>", "SubjectTokenType": "ACCESS_TOKEN", "RequestedTokenType": "ACCESS_TOKEN", "Scopes": ["files/public.read"]}
HTTP 200
response: {
  "ExpiresIn": 3562,
  "IssuedToken": "<IssuedToken: 973 chars>",
  "IssuedTokenType": "ACCESS_TOKEN",
  "Scopes": [
    "files/public.read"
  ],
  "TokenType": "Bearer"
}

呼び出しに使ったスクリプトは、リクエスト JSON のファイルと発行トークンの保存先を引数に取ります。SDK を使わないため、Content-Type と X-Amz-Target をスクリプトで設定しています。

get-derived-token.py
#!/usr/bin/env python3
"""GetDerivedToken を HTTPS で直接呼ぶ(2026-11-13 時点で CLI/SDK に無いため)。
使い方: get-derived-token.py <リクエストJSONのファイル> [発行トークンの保存先]
- 署名なし。ClientId(+ ClientSecret)で認証する。
- 標準出力には、秘匿値(ClientSecret・SubjectToken・ActorToken・IssuedToken)を伏せたリクエストと応答を出す。
"""
import json, sys, urllib.request, urllib.error

REGION = "ap-northeast-1"
req = json.load(open(sys.argv[1]))
mask = lambda d: {k: (f"<{k}: {len(v)} chars>" if k in ("ClientSecret", "SubjectToken", "ActorToken", "IssuedToken") else v) for k, v in d.items()}
print("request:", json.dumps(mask(req), ensure_ascii=False))
r = urllib.request.Request(
    f"https://cognito-idp.{REGION}.amazonaws.com/",
    data=json.dumps(req).encode(),
    headers={
        "Content-Type": "application/x-amz-json-1.1",
        "X-Amz-Target": "AWSCognitoIdentityProviderService.GetDerivedToken",
    },
)
try:
    resp = urllib.request.urlopen(r)
    status, body = resp.status, resp.read()
except urllib.error.HTTPError as e:
    status, body = e.code, e.read()
print("HTTP", status)
out = json.loads(body)
if "IssuedToken" in out and len(sys.argv) > 2:
    open(sys.argv[2], "w").write(out["IssuedToken"])
print("response:", json.dumps(mask(out), ensure_ascii=False, indent=2))
sys.exit(0 if status == 200 else 1)

Lambda でスコープを判定する API

API は、API Gateway の REST API に Cognito オーソライザーを付け、その背後に Lambda を置いた構成です。Lambda は、トークンのスコープに応じて S3 の一覧を返します。

  • files/private.read がある場合: バケット全体の一覧
  • files/public.read だけの場合: public/ 配下の一覧

Lambda のロールにはバケット全体への s3:ListBucket を付けています。結果の違いは、S3 の権限ではなく、この Lambda のスコープ判定から生じます。

API 用 Lambda(index.mjs)
// API Gateway (Cognito オーソライザー) の背後で S3 の一覧を返す。
// スコープに応じて ListObjectsV2 の Prefix を変える。判定はこの Lambda の中。
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";

const s3 = new S3Client({});

export const handler = async (event) => {
  const claims = event.requestContext.authorizer.claims;
  const scopes = (claims.scope || "").split(" ");
  const prefix = scopes.includes("files/private.read")
    ? ""
    : scopes.includes("files/public.read")
      ? "public/"
      : null;
  if (prefix === null) {
    return { statusCode: 403, body: JSON.stringify({ message: "no files scope" }) };
  }
  const res = await s3.send(
    new ListObjectsV2Command({ Bucket: process.env.BUCKET, Prefix: prefix }),
  );
  return {
    statusCode: 200,
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      scope: claims.scope,
      aud: claims.aud ?? null,
      client_id: claims.client_id ?? null,
      act: claims.act ?? null,
      prefix,
      keys: (res.Contents ?? []).map((o) => o.Key),
    }),
  };
};

アクセストークンで API を呼ぶため、メソッドに認可スコープを設定しました。ここでは2つのスコープのどちらかを持つトークンを通す設定にしています。

API Gateway メソッドの作成コマンド
aws apigateway put-method --rest-api-id <rest-api-id> --resource-id <resource-id> --http-method GET --authorization-type COGNITO_USER_POOLS --authorizer-id <authorizer-id> --authorization-scopes files/public.read files/private.read

API Gateway の認可スコープ

Lambda に書いたスコープの判定とは別に、API Gateway にはスコープでリクエストを拒否する機能があります。ユーザーのトークンと絞ったトークンを渡して比べました。

既存の API に GET /private を追加しました。認可スコープは files/private.read だけにして、同じ Lambda につないでいます。

aws apigateway put-method --rest-api-id <rest-api-id> --resource-id <resource-id> --http-method GET --authorization-type COGNITO_USER_POOLS --authorizer-id <authorizer-id> --authorization-scopes files/private.read

AgentCore Gateway の allowedScopes

AgentCore Gateway の JWT オーソライザーにも、スコープでリクエストを拒否する機能があります。同じ2つのトークンを渡して比べました。

AgentCore Gateway は、プロトコルに MCP を指定して ap-northeast-1 に作成しました。JWT オーソライザーには次を設定しています。

  • discovery URL: Cognito ユーザープールの URL
  • allowedClients: ユーザー用の app client と mcp-server(絞ったトークンの client_id が mcp-server のため)
  • allowedScopes: files/private.read

ターゲットは、バケット全体のキーを返す list_files ツールを持つ Lambda です。MCP のリクエスト(tools/list と tools/call)は curl で送りました。

AgentCore Gateway の作成コマンドとツール用 Lambda

AgentCore Gateway のオーソライザーの設定(gw-authorizer.json)と、Gateway の作成コマンドです。

{"customJWTAuthorizer":{"discoveryUrl":"https://cognito-idp.ap-northeast-1.amazonaws.com/<user-pool-id>/.well-known/openid-configuration","allowedClients":["<user-client-id>","<mcp-server-client-id>"],"allowedScopes":["files/private.read"]}}
aws bedrock-agentcore-control create-gateway --name devio-tx-1113-gw --role-arn <gateway-role-arn> --protocol-type MCP --authorizer-type CUSTOM_JWT --authorizer-configuration file://gw-authorizer.json

ターゲットの作成コマンドです。credential-provider-configurations には、[{"credentialProviderType":"GATEWAY_IAM_ROLE"}] を書いた gw-target-cred.json を渡しました。

aws bedrock-agentcore-control create-gateway-target --gateway-identifier <gateway-id> --name tool --target-configuration '{"mcp":{"lambda":{"lambdaArn":"<tool-lambda-arn>","toolSchema":{"inlinePayload":[{"name":"list_files","description":"List all object keys in the bucket","inputSchema":{"type":"object","properties":{}}}]}}}}' --credential-provider-configurations file://gw-target-cred.json

ツール用 Lambda(index.mjs)です。

// AgentCore Gateway の Lambda ターゲット用。スコープの判定は含まず、バケット全体の一覧を返す。
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";

const s3 = new S3Client({});

export const handler = async () => {
  const res = await s3.send(new ListObjectsV2Command({ Bucket: process.env.BUCKET }));
  return { keys: (res.Contents ?? []).map((o) => o.Key) };
};

呼び出しは、トークンを Authorization ヘッダーに入れた次の形です。tools/list のときは method を tools/list にし、params を外しました。

curl -sS -i -X POST -H "Authorization: Bearer <token>" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"tool___list_files","arguments":{}}}' https://<gateway-id>.gateway.bedrock-agentcore.ap-northeast-1.amazonaws.com/mcp

検証結果

Lambda の判定での結果(S3 の一覧)

ユーザーのトークンと、交換で得たトークンを、同じ API に渡しました。一覧の対象のバケットは同じです。

ユーザーのトークン:

{"scope":"aws.cognito.signin.user.admin files/private.read files/public.read","aud":null,"client_id":"<user-client-id>","act":null,"prefix":"","keys":["private/contract.pdf","private/salary.csv","public/readme.txt","public/sample.csv"]}

交換で得たトークン:

{"scope":"files/public.read","aud":null,"client_id":"<mcp-server-client-id>","act":null,"prefix":"public/","keys":["public/readme.txt","public/sample.csv"]}

API Gateway の認可スコープでの結果

GET /private に渡すと、ユーザーのトークンは HTTP 200 で4件を返しました。絞ったトークンは、前節の API では2件を返しましたが、GET /private では HTTP 401 Unauthorized で拒否されました。

AgentCore Gateway の allowedScopes での結果

ユーザーのトークンでは、tools/call が HTTP 200 で4件を返しました。絞ったトークンは、tools/list も tools/call も HTTP 403 の insufficient_scope(scope="files/private.read")で拒否されました。ツール用 Lambda にはスコープの判定を書いていないので、拒否したのは Gateway です。

クレームの変化

交換前後のクレームを比べました。右の3列は、いずれも mcp-server の依頼で交換したトークンです。アクタートークンと Audiences の指定が列ごとに異なります。

クレーム ユーザーのトークン 交換後 交換後(アクタートークン付き) 交換後(アクタートークン付き、Audiences に files)
scope aws.cognito.signin.user.admin files/private.read files/public.read files/public.read files/public.read files/public.read
client_id ユーザー用 app client mcp-server mcp-server mcp-server
act なし なし mcp-actor-m2m mcp-actor-m2m
aud なし なし なし files
sub, origin_jti ユーザーのトークンの値 左と同じ 左と同じ 左と同じ
exp 1794543505 1794543504 1794543504 1794543504

下流の API は、sub でユーザーを、client_id で交換を依頼した app client を識別できます。有効期限は元のトークンの期限までで、今回は exp が元のトークンより1秒早くなりました。応答の ExpiresIn は 3562 で、交換後のトークンの期限までの秒数でした。応答にリフレッシュトークンは含まれませんでした。

アクタートークンと act クレーム

アクタートークンを付けて交換しました。ActorToken には GetClientToken で得たアクセストークンを、ActorTokenType には ACCESS_TOKEN を指定しています。発行されたトークンには act クレームが入りました。

"act": {
  "sub": "<mcp-actor-client-id>",
  "client_id": "<mcp-actor-client-id>",
  "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/<user-pool-id>",
  "token_type": "urn:ietf:params:oauth:token-type:access_token"
}

sub と client_id は、アクタートークンを取得した mcp-actor-m2m の app client ID です。

このトークンを REST API の Cognito オーソライザー経由で Lambda に渡すと、Lambda が受け取る act はオブジェクトではなく JSON 文字列でした。

{"scope":"files/public.read","aud":null,"client_id":"<mcp-server-client-id>","act":"{\"sub\":\"<mcp-actor-client-id>\",\"client_id\":\"<mcp-actor-client-id>\",\"iss\":\"https://cognito-idp.ap-northeast-1.amazonaws.com/<user-pool-id>\",\"token_type\":\"urn:ietf:params:oauth:token-type:access_token\"}","prefix":"public/","keys":["public/readme.txt","public/sample.csv"]}

Lambda 側で act の中身を使うときは、JSON としてパースする必要があります。

audience の指定

Audiences にリソースサーバーの識別子 files を指定すると、発行されたトークンの aud が files になりました。未登録の mcp-files-api を指定すると、HTTP 400 の NotAuthorizedException(Invalid resource requested)になりました。

スコープの要求とエラー

絞ったトークンを SubjectToken にして、そのトークンに無い files/private.read を含む Scopes を要求するとエラーになりました。API Reference には、要求したスコープ、SubjectToken のスコープ、app client に許可されたスコープの共通部分が発行されると書かれています。しかし、2件目は共通部分に files/public.read があるのに、エラーになりました。

SubjectToken ActorToken Scopes 結果
絞ったトークン なし files/private.read HTTP 400 InvalidParameterException: Requested scopes are not permitted for this subject token
絞ったトークン なし files/public.read, files/private.read HTTP 400 InvalidParameterException: Requested scopes are not permitted for this subject token
ユーザーのトークン あり aws.cognito.signin.user.admin, files/public.read HTTP 400 InvalidParameterException: Requested scopes are not permitted for this client
ユーザーのトークン あり 省略 HTTP 200、files/private.read と files/public.read

Scopes を省略すると、ユーザーのトークンにあった aws.cognito.signin.user.admin は含まれませんでした。

まとめ

Cognito のトークン交換で、ユーザーのトークンから、スコープを絞った別のトークンを発行できるようになりました。

絞ったトークンは、files/private.read を要求する API Gateway と AgentCore Gateway では拒否され、Lambda のスコープ判定では public/ 配下の2件だけが返りました。MCP サーバーが下流の API へ渡すトークンのスコープを絞ると、そのトークンが漏れたり悪用されたりしても、実行できる操作をそのスコープの範囲に限定できます。

MCP サーバーから、API Gateway や AgentCore Gateway のように Cognito のスコープで認可できる下流の API を呼ぶ場合は、今回サポートされたトークン交換をお試しください。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事