
CognitoのOAuth 2.0トークン交換でMCP向けにスコープを絞る利用を試してみた
はじめに
2026年11月12日に、Amazon Cognito が OAuth 2.0 トークン交換に対応しました。
トークン交換を使うと、ユーザーに代わって下流の 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 に記載されています。
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 を呼ぶ場合は、今回サポートされたトークン交換をお試しください。










