diff --git a/Controller/Admin/AgentCommerceClientController.php b/Controller/Admin/AgentCommerceClientController.php new file mode 100644 index 0000000..69879a9 --- /dev/null +++ b/Controller/Admin/AgentCommerceClientController.php @@ -0,0 +1,102 @@ +createClient($request, 'acp'); + } + + #[Route(path: '/%eccube_admin_route%/api/oauth/ucp/new', name: 'admin_api_oauth_ucp_new', methods: ['GET', 'POST'])] + public function createUcp(Request $request): RedirectResponse|Response + { + return $this->createClient($request, 'ucp'); + } + + private function createClient(Request $request, string $protocol): RedirectResponse|Response + { + $form = $this->createForm(AgentCommerceClientType::class, null, ['protocol' => $protocol]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $client = new Client( + (string) $form->get('name')->getData(), + (string) $form->get('identifier')->getData(), + (string) $form->get('secret')->getData() + ); + $client->setActive(true); + // エージェントは会員でもブラウザでもなく同意画面を経由できないため、 M2M の + // client_credentials に固定する (authorization_code / refresh_token は付与しない)。 + $client->setGrants(new Grant(OAuth2Grants::CLIENT_CREDENTIALS)); + $client->setScopes(...array_map( + static fn (string $scope): Scope => new Scope($scope), + $form->get('scopes')->getData() + )); + + $this->clientManager->save($client); + + $this->addSuccess('admin.common.save_complete', 'admin'); + + // league はシークレットを保存時にハッシュ化せず、 初回のトークン取得成功時に + // bcrypt へ日和見アップグレードする (ClientRepository::validateClient)。 + // つまり一度使われた後の一覧表示はハッシュ値で、 事業者へ渡す値を復元できない。 + // 発行直後のこの画面でだけ平文を提示する (redirect すると失われるため render する)。 + $response = $this->render('@Api44/admin/OAuth/agent_commerce_client_issued.twig', [ + 'name' => (string) $form->get('name')->getData(), + 'identifier' => (string) $form->get('identifier')->getData(), + 'secret' => (string) $form->get('secret')->getData(), + ]); + // 認証情報を HTML に埋め込む画面なので、 ブラウザや共有端末のキャッシュに残さない + $response->headers->set('Cache-Control', 'no-store, private'); + + return $response; + } catch (\Exception $e) { + $this->addError(trans('admin.common.save_error'), 'admin'); + log_error('エージェントコマース OAuth2 Client 登録エラー', ['exception' => $e, 'protocol' => $protocol]); + } + } + + return $this->render('@Api44/admin/OAuth/agent_commerce_client.twig', [ + 'form' => $form->createView(), + 'protocol' => $protocol, + ]); + } +} diff --git a/Controller/Admin/OAuthController.php b/Controller/Admin/OAuthController.php index 99ed260..8deb0db 100644 --- a/Controller/Admin/OAuthController.php +++ b/Controller/Admin/OAuthController.php @@ -25,6 +25,7 @@ use League\Bundle\OAuth2ServerBundle\ValueObject\Grant; use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri; use League\Bundle\OAuth2ServerBundle\ValueObject\Scope; +use Plugin\Api44\Form\Type\Admin\AgentCommerceClientType; use Plugin\Api44\Form\Type\Admin\ClientType; use Plugin\Api44\Repository\McpTokenRepository; use Plugin\Api44\Service\McpTokenService; @@ -86,6 +87,13 @@ public function index(Request $request): Response return $this->render('@Api44/admin/OAuth/index.twig', [ 'clients' => $clients, 'mcpTokens' => $this->mcpTokenRepository->findAllOrderByCreateDate(), + // エージェントコマース用クライアントのシークレットは一覧で再表示しない (#188)。 + // league は初回のトークン取得成功時に保存値を bcrypt へ差し替えるため、 一覧の値は + // そのまま事業者へ渡せない。 平文は発行直後の画面でだけ提示する。 + 'agentCommerceClientIds' => array_values(array_map( + static fn (ClientInterface $client): string => $client->getIdentifier(), + array_filter($clients, self::isAgentCommerceClient(...)) + )), ]); } @@ -99,8 +107,6 @@ public function index(Request $request): Response #[Route(path: '/%eccube_admin_route%/api/oauth/new', name: 'admin_api_oauth_new', methods: ['GET', 'POST'])] public function create(Request $request): RedirectResponse|Response { - $name = ''; - $builder = $this->formFactory ->createBuilder(ClientType::class); @@ -108,6 +114,7 @@ public function create(Request $request): RedirectResponse|Response $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + $name = (string) $form->get('name')->getData(); $identifier = $form->get('identifier')->getData(); $secret = $form->get('secret')->getData(); @@ -184,6 +191,29 @@ public function clearExpiredTokens(Request $request): RedirectResponse return $this->redirectToRoute('admin_api_oauth'); } + /** + * エージェントコマース (ACP/UCP) 用に発行されたクライアントか判定する. + * + * scope の protocol 接頭辞で判定する ({@link AgentCommerceClientType} が付与する scope)。 + */ + private static function isAgentCommerceClient(ClientInterface $client): bool + { + $prefixes = array_map( + static fn (string $protocol): string => $protocol.':', + array_keys(AgentCommerceClientType::PROTOCOL_SCOPES) + ); + + foreach ($client->getScopes() as $scope) { + foreach ($prefixes as $prefix) { + if (str_starts_with((string) $scope, $prefix)) { + return true; + } + } + } + + return false; + } + /** * @param Client $client * @param FormInterface $form diff --git a/Form/Type/Admin/AgentCommerceClientType.php b/Form/Type/Admin/AgentCommerceClientType.php new file mode 100644 index 0000000..9a171a0 --- /dev/null +++ b/Form/Type/Admin/AgentCommerceClientType.php @@ -0,0 +1,131 @@ + この画面から付与できる scope。 + * + * `Resource/config/services.yaml` の `scopes.available` と同期させること + * (league は available に無い scope を拒否する)。 + * + * `ucp:identity` は**意図的に含めない**。 会員本人の同意のもとで発行する capability であり、 + * Customer を subject とする authorization_code が前提になるため client_credentials では + * 成立しない (eccube-api4#189)。 #189 landing 後に会員同意を伴う別導線として追加する。 + * + * @var array> + */ + public const PROTOCOL_SCOPES = [ + 'acp' => ['acp:checkout', 'acp:catalog'], + 'ucp' => ['ucp:checkout', 'ucp:cart', 'ucp:catalog'], + ]; + + public function __construct( + private readonly EccubeConfig $eccubeConfig, + ) { + } + + /** + * {@inheritdoc} + * + * @throws \Exception + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $scopes = self::PROTOCOL_SCOPES[$options['protocol']]; + + $builder + // どのエージェント事業者向けのクライアントかを後から追えるようにする (Client::name) + ->add('name', TextType::class, [ + 'mapped' => false, + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Length(['max' => $this->eccubeConfig['eccube_stext_len']]), + ], + ]) + ->add('identifier', TextType::class, [ + 'mapped' => false, + 'data' => hash('md5', random_bytes(16)), + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Length(['max' => 32]), + new Assert\Regex(['pattern' => '/^[0-9a-zA-Z]+$/']), + ], + ]) + ->add('secret', TextType::class, [ + 'mapped' => false, + 'data' => hash('sha512', random_bytes(32)), + 'constraints' => [ + new Assert\NotBlank(), + // client_credentials ではシークレットが唯一の認証情報なので下限を設ける + // (既定値は sha512 hex = 128 文字なので、 手で書き換えた場合にだけ効く)。 + new Assert\Length(['min' => 32, 'max' => 128]), + new Assert\Regex(['pattern' => '/^[0-9a-zA-Z]+$/']), + ], + ]) + ->add('scopes', ChoiceType::class, [ + 'choices' => array_combine($scopes, $scopes), + 'expanded' => true, + 'multiple' => true, + 'mapped' => false, + 'constraints' => [ + new Assert\NotBlank(), + // choices 外の scope を弾いているのは実際には ChoiceType 自身である。 + // PRE_SUBMIT で未知値を submitted data から除去し、 POST_SUBMIT で FormError を + // 積む (`ChoiceType::buildForm()`)。 本制約はその機構に依存しないための多層防御で、 + // 制約評価時にはデータが choices 済みに絞られているため通常は発火しない。 + new Assert\All([new Assert\Choice(['choices' => $scopes])]), + ], + ]); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired('protocol'); + $resolver->setAllowedValues('protocol', array_keys(self::PROTOCOL_SCOPES)); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'api_admin_agent_commerce_client'; + } +} diff --git a/Form/Type/Admin/ClientType.php b/Form/Type/Admin/ClientType.php index 1167d38..fc3964e 100644 --- a/Form/Type/Admin/ClientType.php +++ b/Form/Type/Admin/ClientType.php @@ -47,6 +47,14 @@ public function __construct( public function buildForm(FormBuilderInterface $builder, array $options): void { $builder + // 一覧でクライアントの用途を識別できるようにする (Client::name) + ->add('name', TextType::class, [ + 'mapped' => false, + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Length(['max' => $this->eccubeConfig['eccube_stext_len']]), + ], + ]) ->add('identifier', TextType::class, [ 'mapped' => false, 'data' => hash('md5', random_bytes(16)), @@ -61,10 +69,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'data' => hash('sha512', random_bytes(32)), 'constraints' => [ new Assert\NotBlank(), - new Assert\Length(['max' => 128]), + // confidential クライアントは token エンドポイントの認証材料がシークレットのみ + // なので下限を設ける ({@link AgentCommerceClientType} と同条件)。 + new Assert\Length(['min' => 32, 'max' => 128]), new Assert\Regex(['pattern' => '/^[0-9a-zA-Z]+$/']), ], ]) + // エージェントコマース (ACP/UCP) の scope はここに並べない。 grant と scope の + // 組み合わせが protocol ごとに決まるため、 専用の登録導線 + // ({@link AgentCommerceClientType}) から付与する (#188)。 ->add('scopes', ChoiceType::class, [ 'choices' => [ 'read' => 'read', @@ -85,6 +98,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void new Assert\Url(), ], ]) + // client_credentials はエージェントコマース専用の登録導線で固定付与するため、 + // 汎用フォームでは選ばせない (#188)。 ->add('grants', ChoiceType::class, [ 'choices' => [ 'Authorization code' => OAuth2Grants::AUTHORIZATION_CODE, diff --git a/Resource/config/services.yaml b/Resource/config/services.yaml index b58bd1c..031396b 100644 --- a/Resource/config/services.yaml +++ b/Resource/config/services.yaml @@ -27,7 +27,8 @@ league_oauth2_server: encryption_key: '%env(ECCUBE_OAUTH2_ENCRYPTION_KEY)%' # Whether to enable the client credentials grant - enable_client_credentials_grant: false + # エージェントコマース (ACP/UCP) の machine-to-machine インバウンド認証で使用する (#188)。 + enable_client_credentials_grant: true # Whether to enable the password grant enable_password_grant: false @@ -48,7 +49,8 @@ league_oauth2_server: # 既存の `read` / `write` は GraphQL 用。 `mcp:*:read` は MCP サーバ機能 (本体同梱) 用の領域別 read scope。 # role 変換時はそれぞれ `ROLE_OAUTH2_MCP:PRODUCT:READ` 等 (`role_prefix: ROLE_OAUTH2_` で大文字化)。 # GraphQL の認可と MCP の認可を独立させるため名前空間 `mcp:` で分離する。 - available: ['read', 'write', 'mcp:product:read', 'mcp:order:read', 'mcp:customer:read', 'mcp:plugin:read'] + # `acp:*` / `ucp:*` はエージェントコマース (#188) 用で、 `:` 規約に従う。 + available: ['read', 'write', 'mcp:product:read', 'mcp:order:read', 'mcp:customer:read', 'mcp:plugin:read', 'acp:checkout', 'acp:catalog', 'ucp:checkout', 'ucp:cart', 'ucp:catalog', 'ucp:identity'] default: ['read'] persistence: @@ -82,6 +84,20 @@ services: tags: - { name: kernel.event_listener, event: kernel.response, method: onKernelResponse } + # エージェントコマース (#188): EC-CUBE 本体の AgentCommerceOAuth2Authenticator が依存する + # Symfony 標準 AccessTokenHandlerInterface を提供する。league の ResourceServer で + # Bearer トークン (JWT) を検証 (公開鍵での署名/期限/失効) し、付与 scope を UserBadge の + # attributes['scopes'] に載せて返す。本体は handler が無ければ 503 を返す疎結合設計。 + # + # 注意: 下の alias はアプリ全体に効く (この ID で解決されるサービスは常に 1 つだけ)。 + # 将来 access_token firewall や他プラグインが同インターフェイスを autowire / alias すると、 + # 無言で本ハンドラに解決される、 あるいは alias が上書きされて ACP 側が壊れる。 + # 実例: EC-CUBE 本体の app/config/eccube/services_test.yaml は同じ ID にテスト用スタブを + # 登録するため、 本プラグインを入れた環境では本体側テストの解決先が入れ替わる。 + Plugin\Api44\Security\AgentCommerceAccessTokenHandler: + autowire: true + Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface: '@Plugin\Api44\Security\AgentCommerceAccessTokenHandler' + Plugin\Api44\EventListener\UserResolveListener: arguments: - '@Eccube\Security\Core\User\MemberProvider' diff --git a/Resource/locale/messages.en.yaml b/Resource/locale/messages.en.yaml index 27fd301..ea95947 100644 --- a/Resource/locale/messages.en.yaml +++ b/Resource/locale/messages.en.yaml @@ -12,10 +12,13 @@ api: token_endpoint: Token endpoint api_endpoint: API endpoint management: OAuth + name: Name + name_tooltip: 'A name describing what this client is used for (e.g. stock sync batch)' identifier: Client ID identifier_tooltip: Up to 32 alphanumeric characters secret: Client Secret secret_tooltip: Up to 128 alphanumeric characters + secret_none_tooltip: 'This client has no secret (public client).' scope: Scope scope_tooltip: GraphQL Query requires read, Mutation requires write/write Scope scope.read.description: 'Read %shop_name% data' @@ -56,6 +59,21 @@ api: mcp_token.revoke__confirm_title: Revoke MCP token mcp_token.revoke__confirm_message: Are you sure you want to revoke this token? It will stop working immediately. mcp_token.revoke__not_found: The token to revoke was not found (it may already be revoked or expired). It has been removed from the list. + agent_commerce.acp_registration__new: New ACP client + agent_commerce.ucp_registration__new: New UCP client + agent_commerce.acp_registration: ACP client registration + agent_commerce.ucp_registration: UCP client registration + agent_commerce.acp_description: 'Register a client for an agent provider that checks out with ACP (Agentic Commerce Protocol). Register one client per provider (the agent_id recorded on orders is this client ID).' + agent_commerce.ucp_description: 'Register a client for an agent provider that checks out with UCP (Universal Commerce Protocol). Register one client per provider (the agent_id recorded on orders is this client ID).' + agent_commerce.acp_name_tooltip: 'A name identifying the agent provider this client is for (e.g. ChatGPT)' + agent_commerce.ucp_name_tooltip: 'A name identifying the agent provider this client is for (e.g. Gemini)' + agent_commerce.acp_scope_tooltip: 'acp:checkout is required for checkout (create, update and complete orders); acp:catalog for reading catalog data.' + agent_commerce.ucp_scope_tooltip: 'ucp:checkout is required for checkout (create, update and complete orders); ucp:cart for cart operations; ucp:catalog for reading catalog data. ucp:identity (member ID linking) requires the member''s own consent and cannot be granted on this screen.' + agent_commerce.grant_note: 'Agents do not go through a consent screen, so the grant is fixed to client_credentials (machine-to-machine). Redirect URIs are not used.' + agent_commerce.secret_note: 'The client secret is shown only once on the confirmation screen after registration. It cannot be shown again from the list, so copy and store it securely.' + agent_commerce.secret_hidden_tooltip: 'The client secret is shown only on the screen right after registration (it cannot be shown again).' + agent_commerce.issued_title: Agentic commerce client registered + agent_commerce.issued_warning: 'The client secret is shown only once on this screen. Copy and store it securely (it cannot be shown again).' webhook: management: WebHook registration: WebHook Registration diff --git a/Resource/locale/messages.ja.yaml b/Resource/locale/messages.ja.yaml index e7fbf02..32dac2f 100644 --- a/Resource/locale/messages.ja.yaml +++ b/Resource/locale/messages.ja.yaml @@ -12,10 +12,13 @@ api: token_endpoint: Token endpoint api_endpoint: API endpoint management: OAuth管理 + name: 名称 + name_tooltip: 'このクライアントの用途がわかる名前(例: 在庫連携バッチ)' identifier: クライアントID identifier_tooltip: 32文字以下の半角英数 secret: クライアントシークレット secret_tooltip: 128文字以下の半角英数 + secret_none_tooltip: 'このクライアントはシークレットを持ちません(public client)。' scope: スコープ scope_tooltip: GraphQLのQueryにはread, Mutationにはwrite/writeのScopeが必要 scope.read.description: '%shop_name%のデータに対する読み取り' @@ -56,6 +59,21 @@ api: mcp_token.revoke__confirm_title: MCP トークンを失効します mcp_token.revoke__confirm_message: このトークンを失効してよろしいですか? 失効すると即座に利用できなくなります。 mcp_token.revoke__not_found: 失効対象のトークンが見つかりませんでした(既に失効・期限切れの可能性)。 一覧からは削除しました。 + agent_commerce.acp_registration__new: ACP 新規追加 + agent_commerce.ucp_registration__new: UCP 新規追加 + agent_commerce.acp_registration: ACP クライアント登録 + agent_commerce.ucp_registration: UCP クライアント登録 + agent_commerce.acp_description: 'ACP (Agentic Commerce Protocol) でチェックアウトするエージェント事業者向けのクライアントを登録します。 事業者ごとに 1 つ登録してください(受注に記録される agent_id はこのクライアントIDです)。' + agent_commerce.ucp_description: 'UCP (Universal Commerce Protocol) でチェックアウトするエージェント事業者向けのクライアントを登録します。 事業者ごとに 1 つ登録してください(受注に記録される agent_id はこのクライアントIDです)。' + agent_commerce.acp_name_tooltip: 'どのエージェント事業者向けのクライアントかがわかる名前(例: ChatGPT)' + agent_commerce.ucp_name_tooltip: 'どのエージェント事業者向けのクライアントかがわかる名前(例: Gemini)' + agent_commerce.acp_scope_tooltip: 'acp:checkout はチェックアウト(注文の作成・更新・確定)、 acp:catalog はカタログデータの読み取りに必要です。' + agent_commerce.ucp_scope_tooltip: 'ucp:checkout はチェックアウト(注文の作成・更新・確定)、 ucp:cart はカート操作、 ucp:catalog はカタログデータの読み取りに必要です。 ucp:identity(会員 ID 連携)は会員本人の同意が必要なため、 この画面では付与できません。' + agent_commerce.grant_note: 'エージェントは同意画面を経由しないため、 機械間(M2M)認証の client_credentials に固定されます。 リダイレクトURIは使用しません。' + agent_commerce.secret_note: 'クライアントシークレットは登録完了画面で 1 度だけ表示されます。 一覧では再表示できないため、 コピーして安全に保管してください。' + agent_commerce.secret_hidden_tooltip: 'クライアントシークレットは登録完了画面でのみ表示されます(再表示不可)。' + agent_commerce.issued_title: エージェントコマース用クライアントを登録しました + agent_commerce.issued_warning: 'クライアントシークレットは今この画面でのみ表示されます。 コピーして安全に保管してください(再表示はできません)。' webhook: management: WebHook管理 registration: WebHook登録 diff --git a/Resource/template/admin/OAuth/agent_commerce_client.twig b/Resource/template/admin/OAuth/agent_commerce_client.twig new file mode 100644 index 0000000..1cf0ddf --- /dev/null +++ b/Resource/template/admin/OAuth/agent_commerce_client.twig @@ -0,0 +1,171 @@ +{# +This file is part of EC-CUBE + +Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved. + +http://www.ec-cube.co.jp/ + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +#} +{% extends '@admin/default_frame.twig' %} + +{% set menus = ['setting', 'api', 'oauth'] %} + +{% block title %}{{ ('api.admin.oauth.agent_commerce.' ~ protocol ~ '_registration')|trans }}{% endblock %} +{% block sub_title %}{{ 'api.admin.management'|trans }}{% endblock %} + +{% form_theme form '@admin/Form/bootstrap_4_horizontal_layout.html.twig' %} + +{% block main %} +
+ {{ form_widget(form._token) }} +
+
+
+
+
+
+
+ {{ ('api.admin.oauth.agent_commerce.' ~ protocol ~ '_registration')|trans }} +
+
+
+
+
+ +

{{ ('api.admin.oauth.agent_commerce.' ~ protocol ~ '_description')|trans }}

+ + {# name #} +
+
+ +
+
+
+
+ {{ form_widget(form.name) }} +
+ {{ form_errors(form.name) }} +
+
+
+ + {# identifier #} +
+
+ +
+
+
+
+ {{ form_widget(form.identifier) }} +
+ {{ form_errors(form.identifier) }} +
+
+
+ + {# secret #} +
+
+ +
+
+
+
+ {{ form_widget(form.secret) }} +

{{ 'api.admin.oauth.agent_commerce.secret_note'|trans }}

+
+ {{ form_errors(form.secret) }} +
+
+
+ + {# scope #} +
+
+ +
+
+
+
+ {{ form_widget(form.scopes, {'label_attr': {'class': 'checkbox-inline'}}) }} +
+ {{ form_errors(form.scopes, {'label_attr': {'class': 'checkbox-inline'}}) }} +
+
+
+ + {# grant type (固定のため表示のみ) #} +
+
+ +
+
+
+
+ Client credentials +

{{ 'api.admin.oauth.agent_commerce.grant_note'|trans }}

+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+{% endblock %} diff --git a/Resource/template/admin/OAuth/agent_commerce_client_issued.twig b/Resource/template/admin/OAuth/agent_commerce_client_issued.twig new file mode 100644 index 0000000..c35f7b5 --- /dev/null +++ b/Resource/template/admin/OAuth/agent_commerce_client_issued.twig @@ -0,0 +1,89 @@ +{# +This file is part of EC-CUBE + +Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved. + +http://www.ec-cube.co.jp/ + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +#} +{% extends '@admin/default_frame.twig' %} + +{% set menus = ['setting', 'api', 'oauth'] %} + +{% block title %}{{ 'api.admin.oauth.agent_commerce.issued_title'|trans }}{% endblock %} +{% block sub_title %}{{ 'api.admin.management'|trans }}{% endblock %} + +{% block main %} +
+
+
+
+
+ {{ 'api.admin.oauth.agent_commerce.issued_title'|trans }}{% if name %} ({{ name }}){% endif %} +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/Resource/template/admin/OAuth/edit.twig b/Resource/template/admin/OAuth/edit.twig index e9c19ca..ac768cb 100644 --- a/Resource/template/admin/OAuth/edit.twig +++ b/Resource/template/admin/OAuth/edit.twig @@ -34,6 +34,27 @@ file that was distributed with this source code.
+ {# name #} +
+
+ +
+
+
+
+ {{ form_widget(form.name) }} +
+ {{ form_errors(form.name) }} +
+
+
+ {# identifier #}
diff --git a/Resource/template/admin/OAuth/index.twig b/Resource/template/admin/OAuth/index.twig index 6e85795..02a6d51 100644 --- a/Resource/template/admin/OAuth/index.twig +++ b/Resource/template/admin/OAuth/index.twig @@ -80,12 +80,27 @@ file that was distributed with this source code.
+ {# 列を追加したことで既定の等分割では見出しが折り返すため、 幅を明示する #} + + + + + + + + + + @@ -107,11 +122,20 @@ file that was distributed with this source code. {% for client in clients %} +
+ {{ 'api.admin.oauth.name'|trans }} + {{ 'api.admin.oauth.identifier'|trans }}
{{ client.name }} - + {%- if client.identifier in agentCommerceClientIds -%} + {# 発行直後の画面でのみ平文を提示する (一覧の保存値は初回利用後にハッシュへ変わる) #} + - + {%- elseif client.Secret is empty -%} + {# public client (DCR 登録等) はシークレットを持たない #} + - + {%- else -%} + + {%- endif -%} {% for scope in client.scopes %} diff --git a/Security/AgentCommerceAccessTokenHandler.php b/Security/AgentCommerceAccessTokenHandler.php new file mode 100644 index 0000000..a8fc9a2 --- /dev/null +++ b/Security/AgentCommerceAccessTokenHandler.php @@ -0,0 +1,80 @@ +) として載せる。 + * 本体はこの attributes から scope を取り出し ScopeRegistry で protocol×capability を照合する。 + * subject (UserBadge identifier) は OAuth2 クライアント識別子 (client_credentials のため会員は伴わない)。 + * + * **`role_prefix: ROLE_OAUTH2_` による scope → role 変換は経由しない**。 返す InMemoryUser には + * `ROLE_OAUTH2_CLIENT` だけを載せるため、 `ROLE_OAUTH2_ACP:CHECKOUT` のようなロールは生成されない。 + * GraphQL / MCP 経路は role ベースで認可するのに対し、 エージェントコマースは attributes の scope を + * 本体が直接照合する流儀になる。 `access_control` や `is_granted()` で `ROLE_OAUTH2_` を + * 期待しないこと (認可は本体の AgentCommerceOAuth2Authenticator 側にある)。 + */ +final class AgentCommerceAccessTokenHandler implements AccessTokenHandlerInterface +{ + public function __construct( + private readonly ResourceServer $resourceServer, + private readonly ServerRequestFactoryInterface $serverRequestFactory, + ) { + } + + public function getUserBadgeFrom(string $accessToken): UserBadge + { + // league の ResourceServer は PSR-7 リクエスト前提のため、Authorization ヘッダだけ持つ + // 最小のリクエストを組み立てて検証に通す。 + $request = $this->serverRequestFactory + ->createServerRequest('GET', 'https://localhost/') + ->withHeader('Authorization', 'Bearer '.$accessToken); + + try { + $validated = $this->resourceServer->validateAuthenticatedRequest($request); + } catch (OAuthServerException $e) { + // 署名不正・期限切れ・失効・形式不正はすべて認証失敗 (401) に正規化する。 + throw new BadCredentialsException('Invalid OAuth2 access token.', 0, $e); + } + + $clientId = (string) ($validated->getAttribute('oauth_client_id') ?? ''); + + $rawScopes = $validated->getAttribute('oauth_scopes'); + $scopes = is_array($rawScopes) + ? array_values(array_map(static fn ($scope): string => (string) $scope, $rawScopes)) + : []; + + return new UserBadge( + '' !== $clientId ? $clientId : 'oauth2-client', + // client_credentials は会員を伴わないため、provider 探索を避けて軽量ユーザーを返す。 + // 本体は identifier と attributes のみ参照するが、getUser() 呼び出しにも備える。 + static fn (string $identifier): InMemoryUser => new InMemoryUser($identifier, null, ['ROLE_OAUTH2_CLIENT']), + ['scopes' => $scopes], + ); + } +} diff --git a/Tests/Security/AgentCommerceAccessTokenHandlerTest.php b/Tests/Security/AgentCommerceAccessTokenHandlerTest.php new file mode 100644 index 0000000..9de1e8b --- /dev/null +++ b/Tests/Security/AgentCommerceAccessTokenHandlerTest.php @@ -0,0 +1,100 @@ +psr17Factory = new Psr17Factory(); + } + + public function testReturnsUserBadgeWithClientIdAndScopes(): void + { + $validated = $this->psr17Factory->createServerRequest('GET', 'https://localhost/') + ->withAttribute('oauth_client_id', 'acp-client-1') + ->withAttribute('oauth_scopes', ['acp:checkout', 'acp:catalog']); + + $handler = $this->createHandlerReturning($validated); + $badge = $handler->getUserBadgeFrom('valid-jwt'); + + self::assertSame('acp-client-1', $badge->getUserIdentifier(), 'subject は OAuth2 クライアント識別子'); + self::assertSame( + ['acp:checkout', 'acp:catalog'], + $badge->getAttributes()['scopes'], + '付与 scope は UserBadge attributes の scopes に array で載る' + ); + } + + public function testFallsBackToPlaceholderIdentifierWhenClientIdMissing(): void + { + $validated = $this->psr17Factory->createServerRequest('GET', 'https://localhost/') + ->withAttribute('oauth_scopes', ['ucp:checkout']); + + $handler = $this->createHandlerReturning($validated); + $badge = $handler->getUserBadgeFrom('valid-jwt'); + + self::assertSame('oauth2-client', $badge->getUserIdentifier()); + self::assertSame(['ucp:checkout'], $badge->getAttributes()['scopes']); + } + + public function testReturnsEmptyScopesWhenAttributeMissing(): void + { + $validated = $this->psr17Factory->createServerRequest('GET', 'https://localhost/') + ->withAttribute('oauth_client_id', 'acp-client-1'); + + $handler = $this->createHandlerReturning($validated); + $badge = $handler->getUserBadgeFrom('valid-jwt'); + + self::assertSame([], $badge->getAttributes()['scopes'], 'scope クレームが無ければ空配列'); + } + + public function testThrowsBadCredentialsWhenTokenInvalid(): void + { + $resourceServer = $this->createMock(ResourceServer::class); + $resourceServer->method('validateAuthenticatedRequest') + ->willThrowException(OAuthServerException::accessDenied('invalid token')); + + $handler = new AgentCommerceAccessTokenHandler($resourceServer, $this->psr17Factory); + + $this->expectException(BadCredentialsException::class); + $handler->getUserBadgeFrom('expired-or-tampered-jwt'); + } + + private function createHandlerReturning(ServerRequestInterface $validated): AgentCommerceAccessTokenHandler + { + $resourceServer = $this->createMock(ResourceServer::class); + $resourceServer->method('validateAuthenticatedRequest')->willReturn($validated); + + return new AgentCommerceAccessTokenHandler($resourceServer, $this->psr17Factory); + } +} diff --git a/Tests/Web/Admin/AgentCommerceClientControllerTest.php b/Tests/Web/Admin/AgentCommerceClientControllerTest.php new file mode 100644 index 0000000..4766f5e --- /dev/null +++ b/Tests/Web/Admin/AgentCommerceClientControllerTest.php @@ -0,0 +1,177 @@ +client->request('GET', $this->generateUrl('admin_api_oauth_acp_new')); + + $this->assertTrue($this->client->getResponse()->isSuccessful()); + $this->assertSame( + ['acp:checkout', 'acp:catalog'], + $this->scopeChoices($crawler), + 'ACP の画面には acp: の scope だけを提示する' + ); + } + + public function testUcpFormExcludesIdentityScope(): void + { + $crawler = $this->client->request('GET', $this->generateUrl('admin_api_oauth_ucp_new')); + + $this->assertTrue($this->client->getResponse()->isSuccessful()); + // ucp:identity は Customer subject の authorization_code が前提 (eccube-api4#189) のため提示しない + $this->assertSame(['ucp:checkout', 'ucp:cart', 'ucp:catalog'], $this->scopeChoices($crawler)); + } + + public function testFormHasNoRedirectUriAndGrantChoice(): void + { + $crawler = $this->client->request('GET', $this->generateUrl('admin_api_oauth_acp_new')); + + // grant は client_credentials 固定、 redirect_uri は不使用なので入力させない + $this->assertCount(0, $crawler->filter('input[name="api_admin_agent_commerce_client[redirect_uris]"]')); + $this->assertCount(0, $crawler->filter('input[name^="api_admin_agent_commerce_client[grants]"]')); + } + + public function testCreateAcpClientFixesGrantToClientCredentials(): void + { + $identifier = 'acptestclient'.random_int(1000, 9999); + $this->submitCreate('admin_api_oauth_acp_new', $identifier, ['acp:checkout'], 'ChatGPT'); + + $client = $this->findClient($identifier); + + $this->assertNotNull($client, 'ACP クライアントが登録されること'); + $this->assertSame('ChatGPT', $client->getName(), 'どの事業者向けかを一覧で追えるよう名称を保持する'); + $this->assertSame( + [OAuth2Grants::CLIENT_CREDENTIALS], + array_map(strval(...), $client->getGrants()), + 'grant は client_credentials に固定される (authorization_code / refresh_token を持たない)' + ); + $this->assertSame(['acp:checkout'], array_map(strval(...), $client->getScopes())); + } + + public function testCreateShowsSecretOnceAndNotInList(): void + { + $identifier = 'acpsecretclient'.random_int(1000, 9999); + $secret = 'acponetimesecret0123456789abcdef'; + + $crawler = $this->client->request('POST', $this->generateUrl('admin_api_oauth_acp_new'), [ + 'api_admin_agent_commerce_client' => [ + 'name' => 'ChatGPT', + 'identifier' => $identifier, + 'secret' => $secret, + 'scopes' => ['acp:checkout'], + '_token' => 'dummy', + ], + ]); + + // 発行直後の画面でだけ平文を提示する (redirect すると失われる) + $this->assertTrue($this->client->getResponse()->isSuccessful()); + $this->assertSame($secret, trim($crawler->filter('#agent_commerce_client_secret')->text())); + $this->assertStringContainsString( + 'no-store', + (string) $this->client->getResponse()->headers->get('Cache-Control'), + '認証情報を含む画面はキャッシュさせない' + ); + + // 一覧では再表示しない (league が初回利用時に保存値をハッシュへ差し替えるため) + $crawler = $this->client->request('GET', $this->generateUrl('admin_api_oauth')); + $row = $crawler->filter('#client-'.$identifier); + $this->assertCount(1, $row); + $this->assertStringNotContainsString($secret, $row->html()); + $this->assertCount(0, $row->filter('input.copy-secret[value="'.$secret.'"]')); + } + + public function testCreateRejectsScopeOfAnotherProtocol(): void + { + $identifier = 'acpcrossclient'.random_int(1000, 9999); + // ACP の導線へ UCP の scope を送り込む (フォームバイパス) + $this->submitCreate('admin_api_oauth_acp_new', $identifier, ['ucp:checkout']); + + $this->assertNull($this->findClient($identifier), 'protocol を跨いだ scope では登録されない'); + } + + public function testCreateRejectsTooShortSecret(): void + { + $identifier = 'acpweaksecret'.random_int(1000, 9999); + $this->submitCreate('admin_api_oauth_acp_new', $identifier, ['acp:checkout'], 'agent', 'short'); + + $this->assertNull( + $this->findClient($identifier), + 'client_credentials ではシークレットが唯一の認証情報なので、 短いシークレットでは登録させない' + ); + } + + public function testIdentityScopeIsNotGrantableFromThisFlow(): void + { + $identifier = 'ucpidentityclient'.random_int(1000, 9999); + $this->submitCreate('admin_api_oauth_ucp_new', $identifier, ['ucp:identity']); + + $this->assertNull( + $this->findClient($identifier), + 'ucp:identity は会員同意 (Customer authorization_code) が前提のため client_credentials では付与できない' + ); + $this->assertNotContains('ucp:identity', AgentCommerceClientType::PROTOCOL_SCOPES['ucp']); + } + + /** + * @return list + */ + private function scopeChoices(\Symfony\Component\DomCrawler\Crawler $crawler): array + { + return $crawler->filter('input[name="api_admin_agent_commerce_client[scopes][]"]') + ->each(static fn (\Symfony\Component\DomCrawler\Crawler $node): string => (string) $node->attr('value')); + } + + /** + * @param list $scopes + */ + private function submitCreate(string $route, string $identifier, array $scopes, string $name = 'agent', string $secret = self::VALID_SECRET): void + { + $this->client->request('POST', $this->generateUrl($route), [ + 'api_admin_agent_commerce_client' => [ + 'name' => $name, + 'identifier' => $identifier, + 'secret' => $secret, + 'scopes' => $scopes, + '_token' => 'dummy', + ], + ]); + } + + private function findClient(string $identifier): ?Client + { + // Web リクエスト側で永続化されたクライアントを読むため、 テスト側の identity map を捨てる + $this->entityManager->clear(); + + return $this->entityManager->getRepository(Client::class)->findOneBy(['identifier' => $identifier]); + } +} diff --git a/Tests/Web/Admin/OAuthControllerTest.php b/Tests/Web/Admin/OAuthControllerTest.php index c0183d7..2634dd5 100644 --- a/Tests/Web/Admin/OAuthControllerTest.php +++ b/Tests/Web/Admin/OAuthControllerTest.php @@ -89,6 +89,9 @@ public function testOAuth2ClientCreateSubmit(): void $this->expected = $formData['identifier']; $this->verify(); + // 一覧で用途を識別できるよう名称を保持する + $this->assertSame($formData['name'], $client->getName()); + $scopes = $client->getScopes(); $this->assertTrue(in_array('read', $scopes)); $this->assertTrue(in_array('write', $scopes)); @@ -145,6 +148,7 @@ protected function createFormData(): array { return [ '_token' => 'dummy', + 'name' => 'stock sync batch', 'identifier' => hash('md5', random_bytes(16)), 'secret' => hash('sha512', random_bytes(32)), 'scopes' => ['read', 'write'],