![[アップデート] AgentCore Identityに追加された同意ポータルでGitHub連携を試してみた](https://images.ctfassets.net/ct0aopd36mqt/7M0d5bjsd0K4Et30cVFvB6/5b2095750cc8bf73f04f63ed0d4b3546/AgentCore2.png?w=3840&fm=webp)
[アップデート] AgentCore Identityに追加された同意ポータルでGitHub連携を試してみた
こんにちは、ラーメンも大好きなコンサル部の神野です。
Amazon Bedrock AgentCore Identityに、AWSがホストする同意ポータルが追加されました!
この同意ポータルを使うと、外部サービスの認可とログインユーザーを紐付ける処理をAWSに任せられます。コールバック画面やAPIを自分で作成・運用する手間が減るのは嬉しいですね!
実は先日、AgentCore GatewayからGitHub・Slack・Googleカレンダーを使うチャットアプリの記事を書いたばかりです。このときは、認可を完了させるためにまさに自前でAPI Gateway・Lambda・DynamoDBを組み合わせて実装していました。
頑張って実装したところに、早くもマネージドな仕組みが出てしまいました・・・! ちょっと残念なような、便利になって嬉しいような、複雑な気持ちですねw
せっかくなので、前回のアプリを同意ポータルに置き換えて試してみます!認可のために自作していた部分を比べると、こんな形になります!

自作していた部分がバッサリなくなっていい感じですね!
同意ポータルでできること
エージェントが利用する外部サービスを、ユーザー自身が接続するための画面です。Gatewayに登録した接続先が並び、ここからGitHubなどにログインして、エージェントへのアクセス許可を与えられます。
今回使用したGitHub連携用のポータルは、こんな画面です。上の行がGitHub用の接続設定で、その下にGatewayの githubmcp ターゲットが並んでいます。Status欄はどちらも「Not connected」で、まだ認可していない状態となります。

画面は正直かなり無骨ですね・・・! 英語表記でProvider名やターゲット名がそのまま並ぶので、初めて使う方は「どれを接続すればいいの?」と迷いそうです。アプリから案内するときは、操作の説明を一言添えたいところですし、マニュアルなどもあってもいいかもですね。
早速やってみましょう!
やってみる
今回の構成はこんな形です! 上側がポータルでの認可、下側がチャットからGitHubを使う流れです。チャットとポータルは同じCognitoユーザーでログインします。

ソースコードを準備する
ソースコードはこちらです。必要に応じてご参照ください!
使用したバージョンは下記のとおりです!
| 項目 | 使用バージョン |
|---|---|
| Node.js / pnpm | 26.7.0 / 11.20.0 |
| Python | 3.14 |
| Strands Agents / bedrock-agentcore | 1.45.0 / 1.15.1 |
| aws-cdk-lib | 2.261.0 |
| boto3 | 1.43.89 |
Cloneしてすぐに試すことができます!
git clone https://github.com/yuu551/bedrock-agentcore-consent-portal.git consent-portal-demo
cd consent-portal-demo
pnpm install --frozen-lockfile
uv init --bare --python 3.14 portal-setup
uv add --project portal-setup boto3==1.43.89
GitHub OAuth Appを作る
GitHubの「Settings」→「Developer settings」→「OAuth Apps」で検証用アプリを作ります。
Application nameは AgentCore Consent Portal Demo としました。
Homepage URLは http://127.0.0.1:5173、Redirect URIは仮の値として http://127.0.0.1:5173/callback を指定します。

作成後、出力されたClient Secretを次のスクリプトでSecrets Managerへ登録します。
import getpass
import json
import boto3
secret = getpass.getpass('GitHub Client Secret: ')
if not secret:
raise SystemExit('入力が空なので中止しました')
boto3.client('secretsmanager', region_name='us-east-1').create_secret(
Name='consent-demo/github',
SecretString=json.dumps({'client_secret': secret}),
)
print('Secretを登録しました')
ポータル用のログイン設定を追加する
デプロイの前に、前回から今回で変更した設定を見ておきます。
少し分かりづらいですが、ポータルへのログインとGitHubへのアクセスとで接続設定が分かれます。OAuth2 Credential Providerをそれぞれ用意しています。
| 用途 | 使用するクライアント |
|---|---|
| ポータルへのログイン | シークレット付きCognitoアプリクライアント |
| GitHubへのアクセス | GitHub OAuth App |
ポータルのログイン先には、JWT形式のアクセストークンを発行するOIDC認証サービスが必要です。GitHubは外部サービスの接続先として使えますが、ポータルのログイン先には指定できません。
今回はGatewayとトークンの発行元をそろえるため、チャットと同じUser Poolにポータル用クライアントを追加しました。ポータルはシークレットを持つWebアプリの認可コードフローを使うため、チャット用とは分けています。
amplify/backend.ts の設定です。
const portalClient = userPool.addClient('ConsentPortalClient', {
generateSecret: true,
supportedIdentityProviders: [cognito.UserPoolClientIdentityProvider.COGNITO],
oAuth: {
flows: { authorizationCodeGrant: true },
scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL, cognito.OAuthScope.PROFILE],
callbackUrls: [portalUrl ? `${portalUrl}/callback` : 'https://example.com/placeholder'],
},
});
同意ポータルのためにも別途ログイン機構を用意する必要がある点は、少し注意しておきたいところですね。
初回のデプロイとコールバックの設定
先ほど準備したSecretを登録し、GitHubのClient IDを設定してデプロイします。
uv run --project portal-setup python register_secret.py
export AWS_REGION=us-east-1
export GITHUB_CLIENT_ID='作成したOAuth AppのClient ID'
pnpm ampx sandbox --identifier consent-demo --once
生成された amplify_outputs.jsonの custom.githubCallbackUrl を、GitHub OAuth AppのRedirect URIに設定します。

赤枠のRedirect URI欄を出力されたURLへ置き換え、「Update application」で保存します。
ここで、コールバックURLを設定する箇所が3つ出てきます。
GitHubへ登録するURLと、CognitoやGatewayへ設定するURLを取り違えないよう、図にまとめました。

どこにどのURLを設定するかは迷わないようにしたいですね。上手くいかない?となった場合は、この辺りの設定値が正しいか確認してみるのも良いと思います。
同意ポータルを作って再デプロイする
初回のデプロイでGatewayができたので、次にポータルを作ります。ターゲットのコールバックURLにはポータルのURLが必要なため、ここを挟んで2回に分けてデプロイする作りにしました。
執筆時点では、CloudFormationの標準リソース一覧に同意ポータルが見当たりませんでした。カスタムリソースまで用意するのは少し手間なので、今回はポータル作成を scripts/portal.py にまとめています。
response = control.create_consent_portal(
name=config['portalName'],
executionRoleArn=config['portalExecutionRoleArn'],
idpConfig={
'credentialProviderArn': config['portalLoginProviderArn'],
'scopes': ['openid', 'email', 'profile'],
},
sources=[{
'identifier': config['gatewayId'],
'type': 'agentcore-gateway',
}],
tags={'Project': 'consent-portal-demo'},
)
スクリプトはポータルが ACTIVE になるのを待ち、URLを .local/portal.json に保存します。2回目のデプロイでこのファイルを読み込むので、同じ作業ディレクトリで続けて実行します。
実行ロールには設定・シークレットの読み取りと、認可の紐付け・トークン取得の権限が必要です。今回の定義は amplify/backend.ts にあります。
同じsandboxへ設定を反映し、チャットを起動します。
uv run --project portal-setup python scripts/portal.py create
pnpm ampx sandbox --identifier consent-demo --once
pnpm dev --host 127.0.0.1
これで環境が作れたので、早速試してみます!
ちなみに、同意ポータルはAWSコンソールからも作成できます!AgentCoreの「アイデンティティ」を開き、同意ポータル欄の「ポータルの作成」から進めます。

作成画面では、Gatewayやポータルへのログイン用プロバイダー、スコープ、IAMロールを設定できます。

チャットからGitHubを接続する
http://127.0.0.1:5173 を開きます。
初回は「Create Account」タブを選び、メールアドレスとパスワードを入力してユーザーを登録します。メールで届く確認コードを入力したら、チャットへログインできます。

今回はGitHubを接続したいので、右上の「サービスを連携」を開きます。
このボタンには amplify_outputs.json に出力されたポータルURLを設定してあり、クリックすると別タブでポータル画面が開きます。

ポータルにも、チャットと同じCognitoのメールアドレス・パスワードでログインします。左がチャット、右がポータルの画面で同一のものを入力します。

ログインすると、冒頭のポータル画面が表示されます。GitHub用の接続設定の行にある「Connect」を押して、認可を進めてみます。

GitHub側では「AgentCore Consent Portal Demo」がアクセス許可を求めています。Personal user dataとRepositoriesの内容を確認し、下の「Authorize」ボタンで許可します。

GitHubで認可するとポータルへ戻り、上部に緑色の通知が出ました! 接続設定のStatus欄も「Connected」になっています。これでチャットから使えるか試してみます。

ちなみに、接続後のAction欄は「—」で、Disconnectボタンは見当たりませんでした。つなぎ直す機会はあまりないかもしれませんが、ここで解除もできるとちょっと嬉しいですね。GitHub側ではAuthorized OAuth Appsから認可を取り消せます。
また、アカウントメニューにもサインアウトの操作が見当たりませんでした。別ユーザーで検証するときは、シークレットウィンドウなどでログイン状態を分けるとよさそうです。ログアウトもできると少し嬉しいなと思いますね。
チャットへ戻って「私のリポジトリを教えて」と送ると、まず githubmcp___get_me が呼ばれ、ユーザー名とプロフィールが返りました。自作のSession Binding APIをデプロイせずに取得できています!

ロジックも気になりますよね??
同意を済ませた後は、通常のGateway連携と同じでポータルだからと言って特殊なことは一切ないです! チャットでログインしたユーザーのアクセストークンを渡し、Gatewayから取得したツールをエージェントに設定します。GitHubのトークン取得はGatewayとIdentityに任せられるので、アプリ側の呼び出しはシンプルですね。
agent/main.py の通常の呼び出し部分を抜き出すと、こんな形です。
from mcp.client.streamable_http import streamablehttp_client
from strands import Agent
from strands.tools.mcp import MCPClient
gateway = MCPClient(
lambda: streamablehttp_client(
GATEWAY_URL,
headers={"Authorization": f"Bearer {bearer_token}"},
)
)
with gateway:
agent = Agent(model=MODEL_ID, tools=gateway.list_tools_sync())
async for event in agent.stream_async(prompt):
yield event
連携前に質問した場合も試す
先に連携してもらう導線があっても、そのままチャットに質問することはありそうですよね。そこで、未接続時にはポータルへのリンクを表示するようにしました。
別の未接続ユーザーで「自分のGitHubプロフィールを教えて」と送ると、次の案内が表示されました。接続後に同じ質問を再送し、プロフィールが返ることも確認しています。

回答欄に「同意ポータルを開く」ボタンと、接続後に質問を再送する案内が出ています。
同意済みなら先ほどの呼び出しで進められますが、未接続の場合はGatewayから認可要求が返ります。これを検出するため、実際のアプリでは先ほどの Agent に次のフックも登録しています。
agent = Agent(
model=MODEL_ID,
tools=gateway.list_tools_sync(),
hooks=[GatewayAuthHook(event_queue, portal_url=portal_url)],
)
portal_url には作成した同意ポータルのURLを渡します。フックはツール実行後の AfterToolCallEvent で認可要求を検出し、モデルへ返すエラーを案内文に置き換えます。
# agent/gateway_auth.py:ツール実行後のフックから抜粋
auth_url = extract_auth_url(event.result)
if auth_url is None:
return # 認可要求がなければ、通常の結果をそのまま使う
if self._portal_url is not None:
event.result = {
"status": "error",
"toolUseId": event.tool_use.get("toolUseId", ""),
"content": [{"text": "GitHubへの接続が必要です。同意ポータルで接続後、質問を再送してください。"}],
}
event.retry = False
key = provider or "unknown"
if key not in self._notified_providers:
await self._event_queue.put({
"type": "auth_required",
"auth_url": self._portal_url,
**({"provider": provider} if provider else {}),
})
self._notified_providers.add(key)
return
フックからフロントエンドへ認可要求のイベントを送り、画面側で案内文と同意ポータルへのリンクを表示する流れです!
トークンの更新はどうなる?
接続できたところで、トークンの更新も気になりますよね。
外部サービスからリフレッシュトークンが返る構成なら、AgentCore Identityが保管し、アクセストークンの期限切れ時に自動更新します。リフレッシュトークン自体が失効した場合は再認可が必要です。
GitHub OAuth Appでは期限付きトークンを有効にすると、アクセストークンは8時間、リフレッシュトークンは未使用で6か月有効です。
後片付け
ポータルを先に削除してから、Amplifyのsandboxを削除します。
uv run --project portal-setup python scripts/portal.py delete
pnpm ampx sandbox delete --identifier consent-demo
GitHub OAuth Appと、手動登録したSecrets ManagerのSecretも削除してください。
Gatewayとポータルで使うIdP
今回はGatewayとポータルのIdPを揃えていますが、公式ドキュメントによる条件も確認しておきます。GatewayのJWT認証とポータルのログイン用OAuth2 Credential Providerには、同じOIDC issuer(トークンの発行元)が必要です。ポータルの作成時にissuerの一致が検証されます。
今回のように同じCognito User Pool内で、チャット用とポータル用のアプリクライアントを分ける構成にし、GitHubなど外部サービスへの認可は、それぞれ個別のOAuth2 Credential Providerとして設定します。

ポータルのログイン先には、JWT形式のアクセストークンを発行できるOIDC IdPが必要です。ポータル用には、クライアントシークレットを持つWebアプリと認可コードフローを使い、openid スコープを許可します。
Microsoft Entra IDも選択肢になります。設定する際は、テナントのDiscovery URLやトークンの宛先(aud)を指定し、ポータルと組み合わせる場合も、同じissuerとJWTアクセストークンという条件に合わせて構成します。
今回Entra IDは試せなかったので、また違う機会に試してブログに書きたいですね!
おまけ(GitHubの認可を取り消した後に復旧してみる)
Connectにはなっているが繋がらないケースにどう復旧すればよいか分からず困るのでは?という疑問がここで1つあります。私も気になったので、一度接続したGitHubの認可を取り消して、元に戻せるか試してみます!
RevokeしてもポータルはConnectedのまま
まず、同意ポータルでGitHubを接続し、「Connected」になった状態から始めます。
GitHubの「Settings」→「Applications」→「Authorized OAuth Apps」から、今回の「AgentCore Consent Portal Demo」を開きます。「Revoke access」を押し、確認画面で認可を取り消しました。

この状態でチャットでリポジトリ一覧の取得を依頼したところ、処理に失敗したという回答が返ってきました。

その後、同じユーザーでポータルを開き直すと・・・接続設定もターゲットも「Connected」のままですね。

Action欄は「—」で、再接続のボタンも見当たりません。接続済みと表示されているのに使えず、ここからどうすればいいか迷ってしまいますね・・・
コマンドでリセットするとConnectが戻った!
公式ドキュメントによると、AgentCoreは外部サービス側でのトークン取り消しを検知できません。無効になったトークンで再認可する場合は、forceAuthentication: true を指定します。
Gatewayのツール呼び出しでは、params._meta に次の設定を付けると、保存済みの認可を取り除いて新しい認可URLを取得できます。
{
"aws.bedrock-agentcore.gateway/credentialProviderConfiguration": {
"oauthCredentialProvider": {
"forceAuthentication": true
}
}
}
ポータルと同じCognitoユーザーでGatewayへ接続し、forceAuthentication: true を指定して githubmcp___get_me を呼び出してみました。同じリクエストを送るためのスクリプトも載せておきます。記事のプロジェクト内に保存し、ポータルと同じメールアドレス・パスワードを入力して実行します。
scripts/reset-github-consent.mjs
import { readFile } from 'node:fs/promises';
import { createInterface } from 'node:readline/promises';
import { Writable } from 'node:stream';
import { Amplify } from 'aws-amplify';
import { signIn, fetchAuthSession, signOut } from 'aws-amplify/auth';
const outputs = JSON.parse(await readFile(new URL('../amplify_outputs.json', import.meta.url)));
let hidden = false;
const terminal = new Writable({ write(chunk, encoding, done) {
if (!hidden) process.stdout.write(chunk, encoding);
done();
} });
const input = createInterface({ input: process.stdin, output: terminal, terminal: true });
try {
const username = await input.question('ポータルと同じCognitoメールアドレス: ');
process.stdout.write('パスワード: ');
hidden = true;
const password = await input.question('');
hidden = false;
process.stdout.write('\n');
input.close();
Amplify.configure(outputs);
const login = await signIn({ username, password });
if (!login.isSignedIn) throw new Error(`追加の認証が必要です: ${login.nextStep.signInStep}`);
const session = await fetchAuthSession();
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
Authorization: `Bearer ${session.tokens.accessToken}`,
'MCP-Protocol-Version': '2025-11-25',
};
async function send(body) {
const response = await fetch(outputs.custom.gatewayUrl, {
method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(45000),
});
if (!response.ok) throw new Error(`Gateway HTTP ${response.status}`);
const sessionId = response.headers.get('mcp-session-id');
if (sessionId) headers['Mcp-Session-Id'] = sessionId;
if (response.headers.get('content-type')?.includes('text/event-stream')) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
for (;;) {
const chunk = await reader.read();
if (chunk.done) throw new Error('Gatewayの応答が完了しませんでした');
buffer += decoder.decode(chunk.value, { stream: true });
const events = buffer.split(/\r?\n\r?\n/);
buffer = events.pop();
for (const event of events) {
const data = event.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trim()).join('\n');
if (!data) continue;
const message = JSON.parse(data);
if (message.id === body.id) return message;
}
}
} finally { await reader.cancel(); }
}
const text = await response.text();
return text ? JSON.parse(text) : null;
}
const initialized = await send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {
protocolVersion: '2025-11-25', capabilities: { elicitation: { url: {} } },
clientInfo: { name: 'github-consent-reset', version: '1.0.0' },
} });
if (!initialized?.result) throw new Error('Gatewayの初期化に失敗しました');
await send({ jsonrpc: '2.0', method: 'notifications/initialized' });
const result = await send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: {
name: 'githubmcp___get_me', arguments: {},
_meta: { 'aws.bedrock-agentcore.gateway/credentialProviderConfiguration': {
oauthCredentialProvider: { forceAuthentication: true },
} },
} });
if (result?.error?.code !== -32042 || !result.error.data?.elicitations?.some(item => item.url)) {
throw new Error('再認可URLを確認できませんでした');
}
console.log('再認可要求を確認しました。ポータルを再読み込みし、Connectから再接続してください。');
} catch (error) {
console.error(error.message);
process.exitCode = 1;
} finally {
hidden = false;
input.close();
await signOut();
}
pnpm exec node scripts/reset-github-consent.mjs
通常の呼び出しでは認可エラーでしたが、リセットの指定を付けると、エラーコード -32042 とともに新しい認可URLが返りました。今回はそのURLを開く代わりに、同意ポータルを再読み込みしてみます。
すると、「Not connected」に変わり、Connectボタンが戻りました!

再同意するとチャットからも取得できた
戻ってきたConnectを押し、GitHub側で再びAuthorizeします。ポータルが「Connected」に戻ったら、チャットで「新しい会話」を開き、「私のレポジトリを教えて」と送ってみます。
プロフィールが返ってきました! コマンドでの確認に加えて、チャット画面でも復旧を確認できましたね。

今回はGitHubの認可を取り消したケースで、APIからリセットしてポータルで再接続する手順を確認できました。あくまで一例なので、ほかのサービスや設定ミスで接続に失敗した場合は、別の対処が必要かもしれません。このあたりも引き続き深掘りしていきたいですね!
現状はポータル画面だけで完結せず、アプリに再接続処理を用意するか、運用側で復旧をサポートする必要がありそうです。とはいえ、このために独自の実装を増やすのも悩ましいので、ポータルから再接続できるようになると一番嬉しいですね!
おわりに
少しややこしいところはありましたが、前回は認可まわりの実装やコンポーネントがなかなか多かったので、ポータルへ任せてチャット側の開発に集中できるのはありがたいですね!Session Binding用のDynamoやLambdaとかバッサリ消えたのは嬉しいです。
画面の無骨さや再接続周りは少し気になりますが、今後さらにポータルが使いやすくなると嬉しいですね!ユーザーの権限を委任してエージェントとSaaSを連携させたいケースはますます増えてくるはずです。今後のアップデートにも期待しています!
本記事が少しでも参考になりましたら幸いです!最後までご覧いただきありがとうございました!







