diff --git a/samples/python/scenarios/a2a/human-present/cards/run.sh b/samples/python/scenarios/a2a/human-present/cards/run.sh index 2745b78b..aabc983c 100755 --- a/samples/python/scenarios/a2a/human-present/cards/run.sh +++ b/samples/python/scenarios/a2a/human-present/cards/run.sh @@ -130,6 +130,10 @@ echo "-> Starting the Card Processor Agent (port:8003 log:$LOG_DIR/mpp_agent.log $UV_RUN_CMD --package ap2-samples python -m roles.merchant_payment_processor_agent >"$LOG_DIR/mpp_agent.log" 2>&1 & pids+=($!) +echo "-> Starting the OneID Identity Provider (port:8004 log:$LOG_DIR/oneid_identity_provider.log)..." +$UV_RUN_CMD --package ap2-samples python -m roles.oneid_identity_provider >"$LOG_DIR/oneid_identity_provider.log" 2>&1 & +pids+=($!) + echo "" echo "All remote servers are starting." diff --git a/samples/python/src/roles/credentials_provider_agent/tools.py b/samples/python/src/roles/credentials_provider_agent/tools.py index e09b9e01..ad004e37 100644 --- a/samples/python/src/roles/credentials_provider_agent/tools.py +++ b/samples/python/src/roles/credentials_provider_agent/tools.py @@ -88,7 +88,25 @@ async def handle_search_payment_methods( "user_email is required for search_payment_methods" ) if not method_data: - raise ValueError("method_data is required for search_payment_methods") + # AP2 EXTENSION (HOOTL flow): The AP2 spec does not define how a + # Credentials Provider should respond when no CartMandate is present + # (i.e. when the user is pre-authorising a payment method at delegation + # time rather than at checkout). We return all of the user's payment + # methods unfiltered so the shopping agent can present them for selection. + # In a production system a separate "list payment methods" endpoint would + # be more appropriate than overloading the merchant-filtered search. + # See: https://ap2-protocol.org/specification/#52-human-not-present-transaction + payment_methods = account_manager.get_account_payment_methods(user_email) + if os.environ.get("PAYMENT_METHOD") == "x402": + payment_methods = [m for m in payment_methods if m.get("brand") == "x402"] + else: + payment_methods = [m for m in payment_methods if m.get("brand") != "x402"] + aliases = { + "payment_method_aliases": _get_payment_method_aliases(payment_methods) + } + await updater.add_artifact([Part(root=DataPart(data=aliases))]) + await updater.complete() + return merchant_method_data_list = [ PaymentMethodData.model_validate(data) for data in method_data diff --git a/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py b/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py index b40f72e0..1ecdb7f2 100644 --- a/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py +++ b/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py @@ -46,6 +46,26 @@ from common.system_utils import DEBUG_MODE_INSTRUCTIONS +# Keywords used to detect age-restricted items in LLM-generated product labels. +# This is a demo heuristic — in a production system the merchant's catalogue +# would carry explicit age-restriction metadata rather than relying on keyword +# matching. +# AP2 EXTENSION: The AP2 spec defines no mechanism for merchants to signal +# verification requirements within a cart. We set verification_requirements +# on CartContents — the object the merchant cryptographically signs — so the +# requirement is bound to the offer and travels with it. This follows the +# precedent of user_cart_confirmation_required and is proposed as a natural +# typed extension to the CartContents schema. +# See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate +_AGE_RESTRICTED_KEYWORDS = frozenset({ + "wine", "beer", "ale", "lager", "stout", "cider", + "whisky", "whiskey", "vodka", "gin", "rum", "brandy", + "champagne", "prosecco", "spirit", "liqueur", "bourbon", + "alcohol", "tobacco", "cigarette", "cigar", "vape", + "age 18+", "[age 18+ required]", +}) + + async def find_items_workflow( data_parts: list[dict[str, Any]], updater: TaskUpdater, @@ -64,6 +84,10 @@ async def find_items_workflow( You MUST exclude all branding from the PaymentItem `label` field. + If any item is typically age-restricted (alcohol, wine, beer, spirits, + tobacco, or cigarettes), you MUST append ' [Age 18+ required]' to that + item's label field. + %s """ % DEBUG_MODE_INSTRUCTIONS @@ -77,8 +101,18 @@ async def find_items_workflow( ) try: items: list[PaymentItem] = llm_response.parsed - current_time = datetime.now(timezone.utc) + + # Dual-layer age restriction detection: the LLM was instructed to append + # '[Age 18+ required]' to restricted item labels; we also verify here with + # a deterministic keyword check so the flag is reliable regardless of LLM + # compliance. Both checks run — the label provides user-visible context. + age_verification_required = any( + any(kw in item.label.lower() for kw in _AGE_RESTRICTED_KEYWORDS) + for item in items + ) + verification_requirements = ["age_over_18"] if age_verification_required else None + item_count = 0 for item in items: item_count += 1 @@ -88,7 +122,9 @@ async def find_items_workflow( current_time, updater, os.environ.get("PAYMENT_METHOD", "CARD"), + verification_requirements, ) + risk_data = _collect_risk_data(updater) updater.add_artifact([ Part(root=DataPart(data={"risk_data": risk_data})), @@ -108,6 +144,7 @@ async def _create_and_add_cart_mandate_artifact( current_time: datetime, updater: TaskUpdater, payment_method: str, + verification_requirements: list[str] | None = None, ) -> None: """Creates a CartMandate and adds it as an artifact.""" if payment_method == "x402": @@ -154,6 +191,7 @@ async def _create_and_add_cart_mandate_artifact( payment_request=payment_request, cart_expiry=(current_time + timedelta(minutes=30)).isoformat(), merchant_name="Generic Merchant", + verification_requirements=verification_requirements, ) cart_mandate = CartMandate(contents=cart_contents) diff --git a/samples/python/src/roles/merchant_payment_processor_agent/tools.py b/samples/python/src/roles/merchant_payment_processor_agent/tools.py index d09ce6bb..cc87656c 100644 --- a/samples/python/src/roles/merchant_payment_processor_agent/tools.py +++ b/samples/python/src/roles/merchant_payment_processor_agent/tools.py @@ -18,8 +18,10 @@ shopping and purchasing process. """ +import base64 from datetime import datetime from datetime import timezone +import json import logging import os from typing import Any @@ -93,8 +95,10 @@ async def _handle_payment_mandate( debug_mode: Whether the agent is in debug mode. payment_method: The payment method to use (e.g., 'CARD', 'x402'). """ + identity_assertion = _extract_identity_assertion(payment_mandate) + if current_task is None: - await _raise_challenge(updater) + await _raise_challenge(updater, identity_assertion=identity_assertion) return if current_task.status.state == TaskState.input_required: @@ -110,6 +114,7 @@ async def _handle_payment_mandate( async def _raise_challenge( updater: TaskUpdater, + identity_assertion: dict | None = None, ) -> None: """Raises a transaction challenge. @@ -117,9 +122,48 @@ async def _raise_challenge( have an issuer in the demo, so we raise the challenge here. For concreteness, we are using an OTP challenge in this sample. + If a OneID identity assertion is present in the PaymentMandate, it is + surfaced here to show what the payment processor / issuer can see and + how they might use it in a real authorisation decision. + Args: updater: The task updater. + identity_assertion: Optional OneID identity credential extracted from + PaymentMandate.user_authorization, if present. """ + parts = [] + + if identity_assertion: + subject = identity_assertion.get("credential_subject", {}) + assurance = subject.get("assurance_level", "") + framework = subject.get("framework", "") + # Assess whether the trust signal is sufficient for frictionless auth. + # In a real bilateral agreement, the issuer's risk engine would make + # this determination. Here we simulate the decision criteria. + frictionless_eligible = assurance == "high" and framework == "UK_DIATF" + frictionless_note = ( + "This transaction carries a verified identity credential with high " + "assurance under UK_DIATF. Under a bilateral agreement with OneID, " + "an issuer COULD choose to approve this transaction frictionlessly " + "without an OTP challenge. In this demo, standard OTP applies." + if frictionless_eligible + else ( + "No OneID credential with sufficient assurance was found. " + "Standard OTP challenge required." + ) + ) + identity_data = { + "issuer": identity_assertion.get("issuer"), + "verified_scopes": subject.get("verified_scopes", []), + "assurance_level": assurance, + "framework": framework, + "frictionless_eligible": frictionless_eligible, + "frictionless_note": frictionless_note, + } + parts.append( + Part(root=DataPart(data={"oneid.IdentityAssertion": identity_data})) + ) + challenge_data = { "type": "otp", "display_text": ( @@ -129,13 +173,12 @@ async def _raise_challenge( "(Demo only hint: the code is 123)" ), } - text_part = TextPart( + parts.append(Part(root=TextPart( text="Please provide the challenge response to complete the payment." - ) - data_part = DataPart(data={"challenge": challenge_data}) - message = updater.new_agent_message( - parts=[Part(root=text_part), Part(root=data_part)] - ) + ))) + parts.append(Part(root=DataPart(data={"challenge": challenge_data}))) + + message = updater.new_agent_message(parts=parts) await updater.requires_input(message=message) @@ -373,6 +416,27 @@ async def _send_payment_receipt_to_credentials_provider( await credentials_provider.send_a2a_message(message_builder.build()) +def _extract_identity_assertion(payment_mandate: PaymentMandate) -> dict | None: + """Extracts the OneID identity credential from PaymentMandate.user_authorization. + + The user_authorization field carries a base64url-encoded JSON payload + produced by sign_mandates_on_user_device, binding the mandate hashes to + any OneID identity credential obtained during the shopping session. + + Returns the identity_credential dict if present, or None. + """ + user_auth = payment_mandate.user_authorization + if not user_auth: + return None + try: + # Restore stripped base64 padding before decoding. + padded = user_auth + "=" * (4 - len(user_auth) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded)) + return payload.get("identity_credential") + except Exception: + return None + + def _create_text_parts(*texts: str) -> list[Part]: """Helper to create text parts.""" return [Part(root=TextPart(text=text)) for text in texts] diff --git a/samples/python/src/roles/oneid_identity_provider/__main__.py b/samples/python/src/roles/oneid_identity_provider/__main__.py new file mode 100644 index 00000000..17543a09 --- /dev/null +++ b/samples/python/src/roles/oneid_identity_provider/__main__.py @@ -0,0 +1,23 @@ +"""OneID Identity Provider Agent — port 8004.""" + +from collections.abc import Sequence + +from absl import app +from roles.oneid_identity_provider.agent_executor import OneIDIdentityProviderExecutor +from common import server + +AGENT_PORT = 8004 + + +def main(argv: Sequence[str]) -> None: + agent_card = server.load_local_agent_card(__file__) + server.run_agent_blocking( + port=AGENT_PORT, + agent_card=agent_card, + executor=OneIDIdentityProviderExecutor(agent_card.capabilities.extensions), + rpc_url="/a2a/oneid_identity_provider", + ) + + +if __name__ == "__main__": + app.run(main) diff --git a/samples/python/src/roles/oneid_identity_provider/agent.json b/samples/python/src/roles/oneid_identity_provider/agent.json new file mode 100644 index 00000000..a79e0402 --- /dev/null +++ b/samples/python/src/roles/oneid_identity_provider/agent.json @@ -0,0 +1,25 @@ +{ + "name": "OneIDIdentityProvider", + "description": "An agent that verifies user identity using bank-verified data via OneID.", + "capabilities": { + "extensions": [ + { + "uri": "https://github.com/google-agentic-commerce/ap2/v1", + "description": "Supports the Agent Payments Protocol.", + "required": true + } + ] + }, + "skills": [ + { + "id": "identity-verification", + "name": "Identity Verification", + "description": "Verifies user identity and age using UK bank authentication. Returns verified claims as a JWT Verifiable Credential.", + "tags": ["identity", "age-verification", "kyc"] + } + ], + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["application/json"], + "url": "http://localhost:8004/a2a/oneid_identity_provider", + "version": "1.0.0" +} diff --git a/samples/python/src/roles/oneid_identity_provider/agent_executor.py b/samples/python/src/roles/oneid_identity_provider/agent_executor.py new file mode 100644 index 00000000..f8923984 --- /dev/null +++ b/samples/python/src/roles/oneid_identity_provider/agent_executor.py @@ -0,0 +1,41 @@ +"""An A2A Agent Executor for the OneID Identity Provider agent. + +OneID verifies user identity via UK Open Banking: the user authenticates with +their bank (biometrics/PIN) and OneID returns verified claims including age +thresholds, as a JWT Verifiable Credential. + +In order to clearly demonstrate the use of the Agent Payments Protocol A2A +extension, this agent was built directly using the A2A framework. +""" + +from typing import Any + +from . import tools +from common.base_server_executor import BaseServerExecutor +from common.system_utils import DEBUG_MODE_INSTRUCTIONS + + +class OneIDIdentityProviderExecutor(BaseServerExecutor): + """AgentExecutor for the OneID identity provider agent.""" + + _system_prompt = """ + You are OneID, a UK identity verification provider. Your job is to verify + user identity using UK Open Banking authentication and return verified + claims as a Verifiable Credential. + + Based on the user's request, identify their intent and select the single + correct tool to use. Your only output should be a tool call. + Do not engage in conversation. + + %s + """ % DEBUG_MODE_INSTRUCTIONS + + def __init__(self, supported_extensions: list[dict[str, Any]] = None): + """Initializes the OneIDIdentityProviderExecutor. + + Args: + supported_extensions: A list of extension objects supported by the + agent. + """ + agent_tools = [tools.handle_verify_identity] + super().__init__(supported_extensions, agent_tools, self._system_prompt) diff --git a/samples/python/src/roles/oneid_identity_provider/tools.py b/samples/python/src/roles/oneid_identity_provider/tools.py new file mode 100644 index 00000000..45b8094c --- /dev/null +++ b/samples/python/src/roles/oneid_identity_provider/tools.py @@ -0,0 +1,267 @@ +"""Tools for the OneID Identity Provider Agent. + +AP2 EXTENSION (OneID demo): The AP2 spec does not define an "Identity +Credential Provider" role or specify how identity verification is initiated, +completed, or communicated between agents via A2A. This entire module is a +demo extension illustrating how such a role *could* be integrated within the +AP2 agent-to-agent trust model. + +The verified credential returned here uses the key ap2.identityCredential — +a proposed generic key for the Identity Credential Provider role. In a future +AP2 revision, a canonical mechanism for conveying identity assertions between +agents would standardise this key. +See the AP2 roadmap: https://ap2-protocol.org/specification/#roadmap + +OneID verifies user identity and returns a JWT Verifiable Credential +containing the verified claims (scopes) requested by the Relying Party. +The verification method (e.g. UK Open Banking, document scan) is an +implementation detail not exposed to the RP — only the verified scopes +and assurance level are returned. + +The verification flow mirrors the OTP challenge pattern used by the +merchant payment processor: + 1. First call (current_task is None): issue an identity challenge. + 2. Second call (same task_id, challenge_response present): validate the + response and return a mock JWT VC containing verified_scopes, + assurance_level, and framework. +""" + +from datetime import datetime +from datetime import timezone +from typing import Any + +from a2a.server.tasks.task_updater import TaskUpdater +from a2a.types import DataPart +from a2a.types import Part +from a2a.types import Task +from a2a.types import TaskState +from a2a.types import TextPart +from common import message_utils + + +# AP2 EXTENSION: non-standard data key for the identity credential returned +# by OneID. AP2 v0.1 defines no canonical key for user-side identity +# assertions travelling between agents. +# See: https://ap2-protocol.org/specification/#roadmap +IDENTITY_CREDENTIAL_DATA_KEY = "ap2.identityCredential" + +# Structurally realistic but cryptographically invalid mock JWTs for demo use. +# TODO(production): remove both. The real JWT is issued by the OneID token +# endpoint after the user completes bank authentication and is returned as a +# signed ES256 vc+jwt. See _complete_identity_verification below. + +# Age verification only (HITL): credentialSubject contains age_over_18 only. +_MOCK_JWT_AGE = ( + "eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCJ9" + ".eyJzdWIiOiJ1c2VyIiwiaXNzIjoiaHR0cHM6Ly9vbmVpZC51ayIsImlhdCI6MTc0NTA3" + "MDAwMCwidmMiOnsiY3JlZGVudGlhbFN1YmplY3QiOnsiYWdlX292ZXJfMTgiOnRydWV9fX0" + ".DEMO_SIGNATURE_NOT_CRYPTOGRAPHICALLY_VALID" +) + +# Full identity (HOOTL): credentialSubject contains openid, profile, date_of_birth, age_over_18. +_MOCK_JWT_IDENTITY = ( + "eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCJ9" + ".eyJzdWIiOiJ1c2VyIiwiaXNzIjoiaHR0cHM6Ly9vbmVpZC51ayIsImlhdCI6MTc0NTA3" + "MDAwMCwidmMiOnsiY3JlZGVudGlhbFN1YmplY3QiOnsib3BlbmlkIjp0cnVlLCJwcm9maWxlIjp7" + "ImdpdmVuX25hbWUiOiJCdWdzIiwiZmFtaWx5X25hbWUiOiJCdW5ueSIsIm5hbWUiOiJCdWdzIEJ1" + "bm55In0sImRhdGVfb2ZfYmlydGgiOiIxOTQwLTA3LTI3IiwiYWdlX292ZXJfMTgiOnRydWV9fX0" + ".DEMO_SIGNATURE_NOT_CRYPTOGRAPHICALLY_VALID" +) + + +async def handle_verify_identity( + data_parts: list[dict[str, Any]], + updater: TaskUpdater, + current_task: Task | None, +) -> None: + """Handles an identity verification request. + + First call: issues a bank-authentication challenge (requires_input). + Second call (same task_id): validates the response and returns a JWT VC. + + Args: + data_parts: The incoming data parts from the A2A message. + updater: The task updater for managing task state. + current_task: The current task, or None if this is a new request. + """ + verification_requirements = ( + message_utils.find_data_part("verification_requirements", data_parts) or {} + ) + # Scope the credential to the caller-supplied expiry (e.g. CartMandate or + # IntentMandate expiry) so it cannot be reused beyond the current session. + valid_until = verification_requirements.get("valid_until") + + if current_task is None: + await _raise_identity_challenge(updater) + return + + if current_task.status.state == TaskState.input_required: + challenge_response = ( + message_utils.find_data_part("challenge_response", data_parts) or "" + ) + if challenge_response: + include_profile = bool(verification_requirements.get("profile")) + await _complete_identity_verification( + updater, + auth_code=challenge_response, + valid_until=valid_until, + include_profile=include_profile, + ) + else: + # Re-issue the challenge if no response was provided. + await _raise_identity_challenge(updater) + + +async def _raise_identity_challenge(updater: TaskUpdater) -> None: + """Issues the OneID identity verification challenge. + + In a real OneID flow the user would be redirected to their chosen + identity provider (e.g. their bank via UK Open Banking, or a document + scan service) to authenticate. Here we mock this as a chat-based + confirmation, matching the same pattern the demo uses for the OTP + challenge. + + TODO(production): Replace this stub with a real OneID OIDC journey: + + 1. Build the authorization URL for OneID's controller (from OID discovery + `authorization_endpoint`, typically `…/v2/authorize`) with `client_id`, + `redirect_uri` (registered for the RP), `response_type=code`, + `scope` (e.g. `openid` + claim scopes such as `age_over_18`), `state`, + and PKCE (`code_challenge`, `code_challenge_method=S256`) per integration. + + 2. Send the user to that URL; they complete verification inside OneID (e.g. + bank / IDV). Do not simulate that step in chat. + + 3. After success, the browser returns to `redirect_uri` with `code` and + `state`. Your backend (not the chat) exchanges the code at `POST …/token` + with the `code_verifier` and client authentication. + + 4. Pass through challenge_data whatever the UI needs to open the journey + (full authorize URL and `state` for correlation). Remove the + “(Demo: type 'confirm'…)” prompt when this is live. + + The A2A `requires_input` call remains the same — the calling agent receives + the redirect URL in the DataPart and surfaces it to the user's browser; only + how that URL is produced (real authorize URL vs demo prompt) changes. + + TODO(demo): We do not assume OneID exposes OpenID4VCI, `jwt_vc_json`, or a + credential endpoint. Any VC-shaped artifact for downstream agents is created + or wrapped inside this OneID agent demo, not fetched from OneID. + + Args: + updater: The task updater. + """ + challenge_data = { + "type": "identity_verification", + "display_text": ( + "OneID needs to verify your identity for this purchase. " + "In a real flow you would be redirected to your chosen identity " + "provider to authenticate (e.g. via UK Open Banking or document " + "scan). OneID will confirm the requested claims on your behalf. " + "(Demo: type 'confirm' to simulate completing identity verification)" + ), + } + text_part = TextPart(text="Identity verification required.") + data_part = DataPart(data={"challenge": challenge_data}) + message = updater.new_agent_message( + parts=[Part(root=text_part), Part(root=data_part)] + ) + await updater.requires_input(message=message) + + +async def _complete_identity_verification( + updater: TaskUpdater, + auth_code: str = "", + valid_until: str | None = None, + include_profile: bool = False, +) -> None: + """Returns the mock JWT Verifiable Credential after successful authentication. + + DEMO NOTE: The credential below is W3C VC-shaped but not compliant (missing + @context, snake_case field names, fake JWT) and not cryptographically secure + (the outer JSON is unsigned — claim values could be altered undetected). + Fine for a demo; not for production. + + TODO(production): Replace the mock credential with real claims from OneID's + OIDC flow only: + + 1. `auth_code` contains the authorization code that arrived at your + `redirect_uri` callback (delivered here as the A2A `challenge_response`). + Exchange it at `POST …/token` with `grant_type=authorization_code`, + `code=auth_code`, `redirect_uri`, `code_verifier`, and client + authentication. + + 2. Read verified attributes from `id_token` (JWT) and/or `GET …/userinfo` + with `Authorization: Bearer `. Map those claims into the + structure AP2 expects. + + 3. Validate tokens as required (issuer, audience, signature, expiry). + + 4. Drop or adjust demo-only fields that are not in real responses. + + To make the result cryptographically verifiable by a downstream merchant + or payment processor, wrap the verified claims in a proper `vc+jwt` — a + JWT signed with OneID's ES256 private key, with `typ: vc+jwt` in the + header and the credential subject in the `vc` claim. Verifiers then fetch + OneID's public key from `https://oneid.uk/.well-known/jwks.json` and + verify the signature without trusting the shopping agent's word. + + TODO(demo): The W3C `VerifiableCredential`-style object, `proof.jwt`, and any + jwt_vc_json / OID4VCI semantics are mocked here for the sample. OneID is not + assumed to issue that format; remove `_MOCK_JWT` only when you either stop + pretending to emit a VC or replace it with a clearly labeled adapter built on + OIDC claims from step 1-2. + + Args: + updater: The task updater. + valid_until: ISO 8601 expiry copied from the IntentMandate's intent_expiry. + When present, the credential is scoped to this time so it cannot be + reused beyond the current transaction session. + """ + # The credential_subject contains only the verified claims (scopes) that + # were requested and confirmed. The verification method (e.g. which bank + # or document provider was used) is an internal OneID detail and is NOT + # returned to the Relying Party. + if include_profile: + credential_subject = { + "verified_scopes": ["openid", "profile", "date_of_birth", "age_over_18"], + "openid": True, + "profile": { + "given_name": "Bugs", + "family_name": "Bunny", + "name": "Bugs Bunny", + }, + "date_of_birth": "1940-07-27", + "age_over_18": True, + "assurance_level": "high", + "framework": "UK_DIATF", + } + mock_jwt = _MOCK_JWT_IDENTITY + else: + credential_subject = { + "verified_scopes": ["age_over_18", "openid"], + "age_over_18": True, + "assurance_level": "high", + "framework": "UK_DIATF", + } + mock_jwt = _MOCK_JWT_AGE + + credential = { + "type": "VerifiableCredential", + "issuer": "https://oneid.uk", + "issuance_date": datetime.now(timezone.utc).isoformat(), + "credential_subject": credential_subject, + "proof": { + "type": "jwt", + "jwt": mock_jwt, + }, + } + if valid_until: + # Bind the credential lifetime to the session expiry supplied by the caller. + credential["expiration_date"] = valid_until + data_part = DataPart(data={IDENTITY_CREDENTIAL_DATA_KEY: credential}) + await updater.add_artifact([Part(root=data_part)]) + success_message = updater.new_agent_message( + parts=[Part(root=TextPart(text="Identity verification successful."))] + ) + await updater.complete(message=success_message) diff --git a/samples/python/src/roles/shopping_agent/agent.py b/samples/python/src/roles/shopping_agent/agent.py index 37c91a31..fe349207 100644 --- a/samples/python/src/roles/shopping_agent/agent.py +++ b/samples/python/src/roles/shopping_agent/agent.py @@ -19,9 +19,12 @@ 2. Help complete the purchase of their chosen items. The Google ADK powers this shopping agent, chosen for its simplicity and -efficiency in developing robust LLM agents. +efficiency in developing robust LLM agents. """ +from google.adk.planners import BuiltInPlanner +from google.genai import types + from . import tools from .subagents.payment_method_collector.agent import payment_method_collector from .subagents.shipping_address_collector.agent import shipping_address_collector @@ -34,6 +37,8 @@ max_retries=5, model="gemini-2.5-flash", name="root_agent", + # disable extended thinking — not necessary with explicit instructions and causes slow responses + planner=BuiltInPlanner(thinking_config=types.ThinkingConfig(thinking_budget=0)), instruction=""" You are a shopping agent responsible for helping users find and purchase products from merchants. @@ -49,14 +54,36 @@ 1. Delegate to the `shopper` agent to collect the products the user is interested in purchasing. The `shopper` agent will return a message indicating if the chosen cart mandate is ready or not. - 2. Once a success message is received, delegate to the + 2. Once a cart selection success message is received, delegate to the `shipping_address_collector` agent to collect the user's shipping address. 3. The shipping_address_collector agent will return the user's shipping address. Display the shipping address to the user. 4. Once you have the shipping address, call the `update_cart` tool to - update the cart. You will receive a new, signed `CartMandate` - object. + update the cart with the merchant. + 4b. Display the updated cart to the user — show the item, shipping + address, and final total including shipping. + Check whether the shopper's response mentioned age verification + (look for "Age verification (18+) is required"): + - If age-restricted: include a clear note that this cart + contains age-restricted items and that OneID will verify the + user is 18+ before co-signing the cart mandate. + - If not age-restricted: note that OneID will verify the user's + identity and bind it to the cart mandate. + Immediately (without waiting for user input) call + `request_identity_verification` and append the challenge text + it returns to the same response. Do NOT ask "shall I proceed?" + — the OneID challenge is the single confirmation step. + 4c. Once the user responds (any non-empty reply is accepted in this + demo), call `complete_identity_verification` with their response. + Show the full verification result — verified scopes, framework, + and assurance level. If this was an age verification flow, + highlight the `age_over_18` scope prominently. + 4d. Call the `cosign_cart_mandate` tool and show the result: + - If age-restricted: "Done — your cart has been co-signed. + Age verification (18+) confirmed via OneID." + - If not: "Done — your cart has been co-signed with your + verified OneID identity." 5. Delegate to the `payment_method_collector` agent to collect the user's payment method. 6. The `payment_method_collector` agent will return the user's @@ -71,24 +98,41 @@ human-readable format) and how long it can be refunded (in a human-readable format). In a second block, show the shipping address. Format it all nicely. In a third block, show the user's - payment method alias. Format it nicely. + payment method alias. Format it nicely. In a fourth block titled + 'Identity Verification', show the verified scopes (e.g. + age_over_18, openid), the framework (UK_DIATF), and assurance + level from the OneID credential. If the cart contained + age-restricted items, note explicitly that age_over_18 was + verified as required for this purchase. Note that the + CartMandate has been co-signed with the verified OneID + identity. 10. Confirm with the user they want to purchase the selected item using the selected form of payment. 11. When the user confirms purchase call the following tools in order: a. `sign_mandates_on_user_device` b. `send_signed_payment_mandate_to_credentials_provider` 12. Initiate the payment by calling the `initiate_payment` tool. - 13. If prompted for an OTP, relay the OTP request to the user. - Do not ask the user for anything other than the OTP request. - Once you have an challenge response, display the display_text - from it and then call the `initiate_payment_with_otp` - tool to retry the payment. Surface the result to the user. + 13. If prompted for an OTP, first check whether an + `oneid.IdentityAssertion` DataPart was returned alongside the + challenge. If present, display it to the user BEFORE showing + the OTP prompt — format it clearly as what the payment processor + received, showing: verified scopes, assurance level, framework, + and the frictionless_note (the processor's hypothetical assessment + of whether it COULD have approved without a challenge). Then relay + the OTP request to the user. Do not ask the user for anything + other than the OTP code. Once you have a challenge response, call + `initiate_payment_with_otp`. Surface the result to the user. 14. If the response is a success or confirmation, create a block of text titled 'Payment Receipt'. Ensure its contents includes price, shipping, tax and total price. In a second block, show the shipping address. Format it all nicely. In a third block, show the - user's payment method alias. Format it nicely and give it to the - user. + user's payment method alias. Format it nicely. If identity + verification was performed, include a fourth block titled + 'Identity Verification' showing: the verified scopes (e.g. + age_over_18, openid), framework (UK_DIATF), assurance level, + and a note that the CartMandate was co-signed with the verified + OneID identity. If the cart contained age-restricted items, + note explicitly that age_over_18 was verified as required. Scenario 2: The user first wants you to describe all the data passed between you, @@ -102,17 +146,116 @@ 2. Follow the instructions for Scenario 1 once the user confirms they want to start with their shopping prompt. - Scenario 3: + Scenario 3 — HOOTL (Autonomous / Delegated Purchase): + Trigger when the user wants to delegate a future purchase to the + agent — e.g., "autonomous", "HOOTL", "buy it for me when...", + "as soon as it's available", "without me". This mirrors the AP2 spec + Section 5.2 example: "buy 2 tickets to this concert as soon as they + become available, make sure we're close to the main stage but don't + spend more than $1000." + + AP2 spec Section 5.2 — "Human Not Present Transaction": in HOOTL the + IntentMandate is signed by the user INSTEAD OF the CartMandate. No + CartMandate is produced; the IntentMandate alone authorises autonomous + action on the user's behalf. There is NO product browsing step — the + agent will find and buy the best match autonomously later. + See: https://ap2-protocol.org/specification/#52-human-not-present-transaction + + 1. Tell the user: "I'll set up an autonomous delegation for you — + I need to collect your requirements, shipping address, and payment + method, then verify your identity to sign your Intent Mandate." + Then immediately delegate to the `shopper` agent with the explicit + instruction: "Collect intent for HOOTL autonomous mode." + 2. When the shopper's response contains "HOOTL IntentMandate + confirmed", do NOT output any text to the user. Your only action + is to immediately call transfer_to_agent with + `shipping_address_collector`. Do not wait for user input. + 3. Display the returned shipping address. + 4. Do NOT output any text to the user. Immediately call + transfer_to_agent with `payment_method_collector`. + 5. Display the returned payment method alias. + 6. Call `set_hootl_payment_method` with the alias from step 5. This + records the pre-authorised payment method on the IntentMandate + before it is co-signed. + + AP2 EXTENSION: AP2 spec Section 4.1.2 lists "Chargeable Payment + Methods" as a component of the IntentMandate for HOOTL flows, but + v0.1 defines no field schema for it. Unlike HITL — where payment + lives in a separate PaymentMandate — in HOOTL the payment + authorisation must be IN the IntentMandate before the user's + identity is bound to it. + See: https://ap2-protocol.org/specification/#412-the-intent-mandate + + 7. Call `request_identity_verification`. Then output a single + response with two clearly labelled sections: + + Section 1 — 'Delegation Summary': + - Intent: natural language description from the IntentMandate + - Merchants: Any — agent will find the best option at execution time + - Shipping: the collected address + - Payment: the collected payment method alias + - Mode: Autonomous (no cart confirmation required) + + Section 2 — 'Identity Verification Required': + - Show the challenge text returned by + `request_identity_verification` + - Add: "In a production flow you would be redirected to your + bank to authenticate via OneID. For this demo, type + 'confirm' to proceed." + + End your response and wait for the user to reply. + 8. Once the user responds (any non-empty reply is accepted in this + demo), call `complete_identity_verification`. Show the full + verification result — verified scopes, framework, assurance level. + 9. Call `cosign_intent_mandate`. Show the result. + 10. Display a final summary with two blocks: + Block 1 — 'Intent Mandate': + - Intent: natural language description + - Merchants: Any — agent will find the best option + - Shipping address + - Payment method + - Mode: Autonomous (user_cart_confirmation_required=False) + - Expires: human-readable expiry + Block 2 — 'Identity Verification': + - Verified scopes (e.g., openid, age_over_18) + - Framework (UK_DIATF) + - Assurance level + - Note: IntentMandate co-signed with verified OneID identity + Then close with the following, substituting the placeholders + from the actual IntentMandate: + + "Your Intent Mandate is signed and your identity verified. In a + production system your agent is now authorised to search any + merchant for '[intent description]', reject anything above + [price ceiling], apply your constraints, and pay using + [payment method] — the moment [timing condition] is met, without + you needing to be present. + + Note: per the AP2 spec (Section 5.2), any actor in the + transaction — the merchant or payment provider — may still raise + a challenge that brings you back into the loop, for example for + fraud prevention or regulatory reasons. Your verified OneID + identity is shared with those actors precisely to reduce the + likelihood of that: a bank-verified identity gives merchants and + payment providers the confidence to approve the transaction + frictionlessly, without interrupting you." + + Scenario 4: The users ask you do to anything else. 1. Respond to the user with this message: "Hi, I'm your shopping assistant. How can I help you? For example, you can say 'I want to buy a pair of shoes'" """ % DEBUG_MODE_INSTRUCTIONS, tools=[ + tools.complete_identity_verification, + tools.cosign_cart_mandate, + tools.cosign_intent_mandate, tools.create_payment_mandate, tools.initiate_payment, tools.initiate_payment_with_otp, + tools.request_identity_verification, tools.send_signed_payment_mandate_to_credentials_provider, + tools.set_hootl_payment_method, tools.sign_mandates_on_user_device, tools.update_cart, ], diff --git a/samples/python/src/roles/shopping_agent/remote_agents.py b/samples/python/src/roles/shopping_agent/remote_agents.py index 9c2a492f..9bd3bba0 100644 --- a/samples/python/src/roles/shopping_agent/remote_agents.py +++ b/samples/python/src/roles/shopping_agent/remote_agents.py @@ -41,3 +41,16 @@ EXTENSION_URI, }, ) + + +# AP2 EXTENSION (OneID demo): The AP2 spec does not define an Identity +# Credential Provider role. This client connects to our demo OneID agent, +# which is not part of the standard AP2 agent registry. +# See: https://ap2-protocol.org/specification/#roadmap +oneid_identity_provider_client = PaymentRemoteA2aClient( + name="oneid_identity_provider", + base_url="http://localhost:8004/a2a/oneid_identity_provider", + required_extensions={ + EXTENSION_URI, + }, +) diff --git a/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/agent.py b/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/agent.py index 446f83fe..5ce1b671 100644 --- a/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/agent.py +++ b/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/agent.py @@ -25,6 +25,9 @@ provider, which is then sent to the merchant agent for payment. """ +from google.adk.planners import BuiltInPlanner +from google.genai import types + from . import tools from common.retrying_llm_agent import RetryingLlmAgent from common.system_utils import DEBUG_MODE_INSTRUCTIONS @@ -34,6 +37,8 @@ model="gemini-2.5-flash", name="payment_method_collector", max_retries=5, + # disable extended thinking — not necessary with explicit instructions and causes slow responses + planner=BuiltInPlanner(thinking_config=types.ThinkingConfig(thinking_budget=0)), instruction=""" You are an agent responsible for obtaining the user's payment method for a purchase. @@ -41,6 +46,22 @@ %s When asked to complete a task, follow these instructions: + + If the conversation indicates this is a HOOTL (autonomous) flow — for + example, the user requested autonomous mode, or there is no CartMandate + in the session — follow these steps instead of the numbered steps below: + A. Say: "To authorise your agent to pay on your behalf, please select + a payment method." + B. Call `get_payment_methods` with user_email="bugsbunny@gmail.com" + (the same demo account used for the shipping address). + Present the returned aliases to the user as a numbered list. + C. Ask the user to choose. Always present the list and wait for their + choice, even if there is only one option. + D. Once the user has chosen, transfer back to the root_agent passing + only the chosen alias as a plain string. Do NOT call + `get_payment_credential_token`. + + Otherwise (standard HITL flow): 1. Ensure a CartMandate object was provided to you. 2. Present a clear and organized summary of the cart to the user. The summary should be divided into two main sections: diff --git a/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/tools.py b/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/tools.py index f24fe572..1436ad6b 100644 --- a/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/tools.py +++ b/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/tools.py @@ -18,6 +18,8 @@ shopping and purchasing process. """ +import uuid + from google.adk.tools.tool_context import ToolContext from ap2.types.payment_request import PAYMENT_METHOD_DATA_DATA_KEY @@ -32,27 +34,38 @@ async def get_payment_methods( ) -> list[str]: """Gets the user's payment methods from the credentials provider. - These will match the payment method on the cart being purchased. + In HITL flows, the CartMandate's method_data is used to filter results + to only methods accepted by the merchant. In HOOTL flows (no cart), + all of the user's payment methods are returned unfiltered. Args: user_email: Identifies the user's account tool_context: The ADK supplied tool context. Returns: - A dictionary of the user's applicable payment methods. + A list of the user's applicable payment method aliases. """ - cart_mandate = tool_context.state["cart_mandate"] + cart_mandate = tool_context.state.get("cart_mandate") + + # Reuse context_id set by find_products (HITL) or get_shipping_address + # (HOOTL). If neither has run yet, generate one now. + context_id = tool_context.state.get("shopping_context_id") + if not context_id: + context_id = str(uuid.uuid4()) + tool_context.state["shopping_context_id"] = context_id + message_builder = ( A2aMessageBuilder() - .set_context_id(tool_context.state["shopping_context_id"]) + .set_context_id(context_id) .add_text("Get a filtered list of the user's payment methods.") .add_data("user_email", user_email) ) - for method_data in cart_mandate.contents.payment_request.method_data: - message_builder.add_data( - PAYMENT_METHOD_DATA_DATA_KEY, - method_data.model_dump(), - ) + if cart_mandate: + for method_data in cart_mandate.contents.payment_request.method_data: + message_builder.add_data( + PAYMENT_METHOD_DATA_DATA_KEY, + method_data.model_dump(), + ) task = await credentials_provider_client.send_a2a_message( message_builder.build() ) diff --git a/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/agent.py b/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/agent.py index 0407b2b6..73f1eebb 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/agent.py +++ b/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/agent.py @@ -26,6 +26,9 @@ This is just one of many possible approaches. """ +from google.adk.planners import BuiltInPlanner +from google.genai import types + from . import tools from common.retrying_llm_agent import RetryingLlmAgent from common.system_utils import DEBUG_MODE_INSTRUCTIONS @@ -34,6 +37,8 @@ model="gemini-2.5-flash", name="shipping_address_collector", max_retries=5, + # disable extended thinking — not necessary with explicit instructions and causes slow responses + planner=BuiltInPlanner(thinking_config=types.ThinkingConfig(thinking_budget=0)), instruction=""" You are an agent responsible for obtaining the user's shipping address. diff --git a/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/tools.py b/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/tools.py index fd9e506c..26eb9e9f 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/tools.py +++ b/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/tools.py @@ -18,6 +18,8 @@ shopping and purchasing process. """ +import uuid + from a2a.types import Artifact from google.adk.tools.tool_context import ToolContext @@ -41,9 +43,17 @@ async def get_shipping_address( Returns: The user's shipping address. """ + # In HITL flows shopping_context_id is set by find_products. In HOOTL + # there is no find_products call, so we generate a fresh context ID and + # store it so all subsequent credential-provider calls share the same one. + context_id = tool_context.state.get("shopping_context_id") + if not context_id: + context_id = str(uuid.uuid4()) + tool_context.state["shopping_context_id"] = context_id + message = ( A2aMessageBuilder() - .set_context_id(tool_context.state["shopping_context_id"]) + .set_context_id(context_id) .add_text("Get the user's shipping address.") .add_data("user_email", user_email) .build() diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py index d380fac7..4af9ea75 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py @@ -24,6 +24,9 @@ This is just one of many possible approaches. """ +from google.adk.planners import BuiltInPlanner +from google.genai import types + from . import tools from common.retrying_llm_agent import RetryingLlmAgent from common.system_utils import DEBUG_MODE_INSTRUCTIONS @@ -33,12 +36,43 @@ model="gemini-2.5-flash", name="shopper", max_retries=5, + # disable extended thinking — not necessary with explicit instructions and causes slow responses + planner=BuiltInPlanner(thinking_config=types.ThinkingConfig(thinking_budget=0)), instruction=""" You are an agent responsible for helping the user shop for products. %s - When asked to complete a task, follow these instructions: + When instructed by the root agent to collect intent for HOOTL + (autonomous) mode, follow these instructions instead: + H1. Explain to the user that in HOOTL (autonomous) mode the agent will + act on their behalf without requiring them to confirm the cart. No + merchant needs to be specified — the agent will find the best option + at execution time. + H2. Gather the delegation details one question at a time: + - What: item description and quantity (e.g., "2 concert tickets") + - Constraints: quality or location requirements (e.g., "close to main stage") + - Price ceiling: the maximum total the user is willing to spend + - Timing: when the agent should act (e.g., "as soon as available") + - Refundability: whether items must be refundable (optional) + H3. Create the IntentMandate using `create_intent_mandate` with: + - user_cart_confirmation_required=False + - merchants=[] (no merchant constraint — agent searches freely) + - natural_language_description that captures the full delegation + intent including quantity, constraints, price ceiling, and timing + (e.g., "2 concert tickets — close to main stage — max $1000 — + purchase as soon as available") + H4. Present the IntentMandate to the user, formatted as in step 4 of the + standard flow below, but highlight that User Confirmation Required is + 'No — Autonomous mode' and Merchants is 'Any — agent will find the + best option at execution time'. End with: "By confirming, you are + authorising your agent to complete this purchase autonomously. Shall + I proceed?" + H5. Once confirmed, immediately transfer back to the root_agent. Your + transfer message should be: "HOOTL IntentMandate confirmed." + + When asked to complete a standard (HITL) shopping task, follow these + instructions: 1. Find out what the user is interested in purchasing. 2. Ask clarifying questions one at a time to understand their needs fully. The shopping agent delegates responsibility for helping the user shop for @@ -74,8 +108,8 @@ human-readable relative time (e.g., "in 1 hour", "in 2 days"). After the breakdown, leave a blank line and end with: "Shall I proceed?" - 5. Once the user confirms, use the 'find_products' tool. It will - return a list of `CartMandate` objects. + 5. Once the user confirms the IntentMandate, use the 'find_products' tool. + It will return a list of `CartMandate` objects. 6. For each CartMandate object in the list, create a visually distinct entry that includes the following details from the object: Item: Display the item_name clearly and in bold. diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py index cee16eff..c9415065 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py @@ -124,7 +124,10 @@ def update_chosen_cart_mandate(cart_id: str, tool_context: ToolContext) -> str: ) if cart.contents.id == cart_id: tool_context.state["chosen_cart_id"] = cart_id - return f"CartMandate with ID {cart_id} selected." + age_note = "" + if cart.contents.verification_requirements and "age_over_18" in cart.contents.verification_requirements: + age_note = " Age verification (18+) is required for items in this cart." + return f"CartMandate with ID {cart_id} selected.{age_note}" return f"CartMandate with ID {cart_id} not found." @@ -133,6 +136,7 @@ def _parse_cart_mandates(artifacts: list[Artifact]) -> list[CartMandate]: return find_canonical_objects(artifacts, CART_MANDATE_DATA_KEY, CartMandate) + def _collect_risk_data(tool_context: ToolContext) -> dict: """Creates a risk_data in the tool_context.""" # This is a fake risk data for demonstration purposes. diff --git a/samples/python/src/roles/shopping_agent/tools.py b/samples/python/src/roles/shopping_agent/tools.py index 1d8b7bf1..f49d7d05 100644 --- a/samples/python/src/roles/shopping_agent/tools.py +++ b/samples/python/src/roles/shopping_agent/tools.py @@ -18,8 +18,10 @@ shopping and purchasing process, such as updating a cart or initiating payment. """ +import base64 from datetime import datetime from datetime import timezone +import json import os import uuid @@ -28,9 +30,11 @@ from .remote_agents import credentials_provider_client from .remote_agents import merchant_agent_client +from .remote_agents import oneid_identity_provider_client from ap2.types.contact_picker import ContactAddress from ap2.types.mandate import CART_MANDATE_DATA_KEY from ap2.types.mandate import CartMandate +from ap2.types.mandate import IntentMandate from ap2.types.mandate import PAYMENT_MANDATE_DATA_KEY from ap2.types.mandate import PaymentMandate from ap2.types.mandate import PaymentMandateContents @@ -82,6 +86,310 @@ async def update_cart( return updated_cart_mandate +async def request_identity_verification( + tool_context: ToolContext, + debug_mode: bool = False, +) -> str: + """Requests identity verification from the OneID Identity Provider. + + Sends an A2A request to OneID and returns the challenge text to present + to the user. Works in both HITL and HOOTL flows: + - HITL: called after update_cart; credential is scoped to the CartMandate's + expiry (the transaction window). + - HOOTL: called after create_intent_mandate; credential is scoped to the + IntentMandate's expiry (the delegation window). + + Stores the OneID task ID in state for use by complete_identity_verification. + + AP2 EXTENSION (OneID demo): The AP2 spec does not define when or how a + user-side identity credential is obtained for either HITL or HOOTL flows. + In HITL we obtain it at cart co-signing time; in HOOTL at intent-signing + time. Neither is described in the spec. + HITL ref: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + HOOTL ref: https://ap2-protocol.org/specification/#412-the-intent-mandate + + Args: + tool_context: The ADK supplied tool context. + debug_mode: Whether the agent is in debug mode. + + Returns: + The challenge display text to show to the user. + """ + # Scope the credential to whichever mandate is active in this session. + # In HITL the CartMandate is present after update_cart; in HOOTL only the + # IntentMandate exists (no cart has been selected yet). + cart_mandate = tool_context.state.get("cart_mandate") + intent_mandate = tool_context.state.get("intent_mandate") + if cart_mandate: + valid_until = cart_mandate.contents.cart_expiry + elif intent_mandate: + valid_until = intent_mandate.intent_expiry + else: + valid_until = None + + # shopping_context_id is set by find_products; in HOOTL (no find_products + # call) it may not be present, so we omit it rather than raise a KeyError. + context_id = tool_context.state.get("shopping_context_id") + builder = A2aMessageBuilder() + if context_id: + builder = builder.set_context_id(context_id) + message = ( + builder + .add_text("verify_identity") + .add_data("verification_requirements", { + "age_over_18": True, + "profile": cart_mandate is None, + "date_of_birth": cart_mandate is None, + "valid_until": valid_until, + }) + .add_data("debug_mode", debug_mode) + .build() + ) + task = await oneid_identity_provider_client.send_a2a_message(message) + tool_context.state["oneid_task_id"] = task.id + tool_context.state["oneid_include_profile"] = cart_mandate is None + + if task.status and task.status.message: + for part in (task.status.message.parts or []): + data = getattr(part.root, "data", None) + if data and "challenge" in data: + return data["challenge"].get("display_text", "") + + return ( + "OneID requires you to verify your identity. " + "Type 'confirm' to simulate completing verification." + ) + + +async def complete_identity_verification( + confirmation: str, + tool_context: ToolContext, + debug_mode: bool = False, +) -> str: + """Completes identity verification with the user's confirmation. + + Re-sends the verification request to OneID with the user's response using + the existing task ID, mirroring the OTP challenge-response pattern. Stores + the resulting credential in state for use by cosign_cart_mandate (HITL) or + cosign_intent_mandate (HOOTL). + + AP2 EXTENSION (OneID demo): Storing the credential here for reuse at + mandate co-signing time is not described in the AP2 spec. + HITL ref: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + HOOTL ref: https://ap2-protocol.org/specification/#412-the-intent-mandate + + Args: + confirmation: The user's confirmation response. + tool_context: The ADK supplied tool context. + debug_mode: Whether the agent is in debug mode. + + Returns: + Human-readable verification result including the verified claims. + """ + oneid_task_id = tool_context.state.get("oneid_task_id") + if not oneid_task_id: + raise RuntimeError( + "No OneID task ID found. Call request_identity_verification first." + ) + + cart_mandate = tool_context.state.get("cart_mandate") + intent_mandate = tool_context.state.get("intent_mandate") + if cart_mandate: + valid_until = cart_mandate.contents.cart_expiry + elif intent_mandate: + valid_until = intent_mandate.intent_expiry + else: + valid_until = None + + context_id = tool_context.state.get("shopping_context_id") + builder = A2aMessageBuilder() + if context_id: + builder = builder.set_context_id(context_id) + message = ( + builder + .set_task_id(oneid_task_id) + .add_text("verify_identity") + .add_data("verification_requirements", { + "age_over_18": True, + "profile": tool_context.state.get("oneid_include_profile", False), + "date_of_birth": tool_context.state.get("oneid_include_profile", False), + "valid_until": valid_until, + }) + .add_data("challenge_response", confirmation) + .add_data("debug_mode", debug_mode) + .build() + ) + task = await oneid_identity_provider_client.send_a2a_message(message) + + identity_credential = None + for artifact in (task.artifacts or []): + for part in (artifact.parts or []): + data = getattr(part.root, "data", None) + if data and "ap2.identityCredential" in data: + identity_credential = data["ap2.identityCredential"] + break + + if identity_credential: + tool_context.state["identity_credential"] = identity_credential + subject = identity_credential.get("credential_subject", {}) + scopes = ", ".join(subject.get("verified_scopes", [])) + return ( + f"Identity verification successful. " + f"Verified scopes: {scopes}. " + f"Framework: {subject.get('framework')}, " + f"assurance level: {subject.get('assurance_level')}. " + f"Issuer: {identity_credential.get('issuer')}." + ) + + return "Identity verification failed or returned no credential." + + +def cosign_cart_mandate(tool_context: ToolContext) -> str: + """Attaches the verified OneID identity credential to the current CartMandate. + + This is the explicit co-signing step that binds the user's verified identity + to the merchant's signed cart offer. The CartMandate already carries the + merchant's authorization (merchant_authorization field); this step adds the + user-side identity (identity_credential field). + + Must be called after update_cart (which fetches the final merchant-signed + CartMandate including shipping) and before sign_mandates_on_user_device. + + AP2 EXTENSION (OneID demo): The AP2 spec states the Cart Mandate should + contain "verifiable identities for the payer and payee" and be + "cryptographically signed by the user, binding their identity and + authorization to a specific transaction", but defines no field for the + user-side identity. We populate the `identity_credential` extension field + with the credential obtained at intent-confirmation time. + See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + + Args: + tool_context: The ADK supplied tool context. + + Returns: + A confirmation message describing the co-signing result. + """ + cart_mandate: CartMandate = tool_context.state.get("cart_mandate") + if not cart_mandate: + raise RuntimeError("No cart mandate found in state. Call update_cart first.") + + identity_credential = tool_context.state.get("identity_credential") + if not identity_credential: + return ( + "No OneID identity credential found in state — CartMandate not" + " co-signed. Identity verification may not have been completed." + ) + + cart_mandate.identity_credential = ( + base64.urlsafe_b64encode( + json.dumps(identity_credential, separators=(",", ":")).encode() + ).decode().rstrip("=") + ) + tool_context.state["cart_mandate"] = cart_mandate + + subject = identity_credential.get("credential_subject", {}) + scopes = ", ".join(subject.get("verified_scopes", [])) + return ( + f"CartMandate co-signed with verified OneID identity. " + f"Verified scopes: {scopes}. " + f"Framework: {subject.get('framework')}, " + f"assurance level: {subject.get('assurance_level')}. " + f"The merchant's cart offer is now bound to the user's verified identity." + ) + + +def set_hootl_payment_method( + payment_method_alias: str, + tool_context: ToolContext, +) -> str: + """Records the pre-authorised payment method on the current IntentMandate. + + In HOOTL flows the user authorises a payment method at delegation time so + the agent can pay without the user being present. Must be called after + the payment_method_collector returns and BEFORE cosign_intent_mandate, so + that the payment authorisation is part of what the user's identity is bound + to when the IntentMandate is co-signed. + + AP2 spec Section 4.1.2 lists "Chargeable Payment Methods" as a component + of the IntentMandate; v0.1 defines no field schema for it. We store the + credentials-provider alias as a placeholder for that data. + See: https://ap2-protocol.org/specification/#412-the-intent-mandate + + Args: + payment_method_alias: The alias returned by the payment_method_collector. + tool_context: The ADK supplied tool context. + + Returns: + Confirmation that the payment method was recorded on the IntentMandate. + """ + intent_mandate: IntentMandate = tool_context.state.get("intent_mandate") + if not intent_mandate: + raise RuntimeError( + "No IntentMandate in state. Call create_intent_mandate first." + ) + intent_mandate.payment_method_reference = payment_method_alias + tool_context.state["intent_mandate"] = intent_mandate + return ( + f"Payment method '{payment_method_alias}' recorded on IntentMandate." + f" It will be included in the co-signed mandate." + ) + + +def cosign_intent_mandate(tool_context: ToolContext) -> str: + """Attaches the verified OneID identity credential to the current IntentMandate. + + This is the HOOTL co-signing step that binds the user's verified identity to + their delegation authority. In HOOTL flows, the IntentMandate is what the + user signs "instead of" the CartMandate (AP2 spec Section 5.2), making it + the correct artefact to carry the identity credential. + + Must be called after complete_identity_verification. + + AP2 EXTENSION (OneID demo): The AP2 spec describes the IntentMandate as + "cryptographically signed by the user" for HOOTL flows (Section 4.1.2) but + defines no field for embedding a third-party identity credential within it. + We add `identity_credential` here so the user's verified identity is + inseparable from the mandate that authorises autonomous action on their + behalf. + See: https://ap2-protocol.org/specification/#412-the-intent-mandate + + Args: + tool_context: The ADK supplied tool context. + + Returns: + A confirmation message describing the co-signing result. + """ + intent_mandate: IntentMandate = tool_context.state.get("intent_mandate") + if not intent_mandate: + raise RuntimeError( + "No intent mandate found in state. Call create_intent_mandate first." + ) + + identity_credential = tool_context.state.get("identity_credential") + if not identity_credential: + return ( + "No OneID identity credential found in state — IntentMandate not" + " co-signed. Identity verification may not have been completed." + ) + + intent_mandate.identity_credential = ( + base64.urlsafe_b64encode( + json.dumps(identity_credential, separators=(",", ":")).encode() + ).decode().rstrip("=") + ) + tool_context.state["intent_mandate"] = intent_mandate + + subject = identity_credential.get("credential_subject", {}) + scopes = ", ".join(subject.get("verified_scopes", [])) + return ( + f"IntentMandate co-signed with verified OneID identity. " + f"Verified scopes: {scopes}. " + f"Framework: {subject.get('framework')}, " + f"assurance level: {subject.get('assurance_level')}. " + f"The user's delegation authority is now bound to their verified identity." + ) + + async def initiate_payment(tool_context: ToolContext, debug_mode: bool = False): """Initiates a payment using the payment mandate from state. @@ -109,6 +417,9 @@ async def initiate_payment(tool_context: ToolContext, debug_mode: bool = False): .add_data("debug_mode", debug_mode) .build() ) + # Note: the OneID identity credential is embedded in payment_mandate + # .user_authorization by sign_mandates_on_user_device — it travels with + # the mandate itself rather than as a separate data part. task = await merchant_agent_client.send_a2a_message(outgoing_message_builder) store_receipt_if_present(task, tool_context) tool_context.state["initiate_payment_task_id"] = task.id @@ -228,15 +539,17 @@ def sign_mandates_on_user_device(tool_context: ToolContext) -> str: cryptographically signed with the user's private key. Note: This is a placeholder implementation. It does not perform any actual - cryptographic operations. It simulates the creation of a signature by - concatenating the mandate hashes. + cryptographic operations. It builds a base64url-encoded JSON payload binding + the mandate hashes to any OneID identity credential that was obtained earlier + in the flow. This mirrors the sd-jwt-vc structure described in the AP2 spec's + user_authorization field. Args: tool_context: The context object used for state management. It is expected to contain the `payment_mandate` and `cart_mandate`. Returns: - A string representing the simulated user authorization signature (JWT). + A string representing the simulated user authorization (base64url JWT). """ payment_mandate: PaymentMandate = tool_context.state["payment_mandate"] cart_mandate: CartMandate = tool_context.state["cart_mandate"] @@ -244,11 +557,32 @@ def sign_mandates_on_user_device(tool_context: ToolContext) -> str: payment_mandate_hash = _generate_payment_mandate_hash( payment_mandate.payment_mandate_contents ) - # A JWT containing the user's digital signature to authorize the transaction. - # The payload uses hashes to bind the signature to the specific cart and - # payment details, and includes a nonce to prevent replay attacks. + + # Build the authorization payload binding mandate hashes to identity. + # In production this would be a signed sd-jwt-vc from the user's secure + # device. Here we encode a structured payload to demonstrate the concept. + authorization_payload = { + "transaction_data": { + "cart_mandate_hash": cart_mandate_hash, + "payment_mandate_hash": payment_mandate_hash, + }, + } + + identity_credential = tool_context.state.get("identity_credential") + if identity_credential: + # AP2 EXTENSION (OneID demo): The AP2 spec defines user_authorization as + # a verifiable presentation containing transaction_data (hashes of the + # CartMandate and PaymentMandateContents). It does not define a field for + # embedding a third-party identity credential within that payload. + # We add `identity_credential` here to bind the OneID VC to the mandate + # hashes, making the verified identity assertion cryptographically + # inseparable from the transaction authorization. + # See: https://ap2-protocol.org/specification/#413-the-payment-mandate-for-ai-agent-visibility-to-payments-ecosystem + authorization_payload["identity_credential"] = identity_credential + + payload_bytes = json.dumps(authorization_payload, separators=(",", ":")).encode() payment_mandate.user_authorization = ( - cart_mandate_hash + "_" + payment_mandate_hash + base64.urlsafe_b64encode(payload_bytes).decode().rstrip("=") ) tool_context.state["signed_payment_mandate"] = payment_mandate return payment_mandate.user_authorization diff --git a/src/ap2/types/mandate.py b/src/ap2/types/mandate.py index c5506689..af6355a7 100644 --- a/src/ap2/types/mandate.py +++ b/src/ap2/types/mandate.py @@ -75,6 +75,44 @@ class IntentMandate(BaseModel): description="When the intent mandate expires, in ISO 8601 format.", ) + # AP2 spec Section 4.1.2 lists "Chargeable Payment Methods: A list or + # category of payment methods the user has authorized for the transaction" + # as a component of the IntentMandate, but v0.1 defines no schema for this + # field. We store the credentials-provider alias as a placeholder. + # Used in HOOTL flows only: the user pre-authorises a payment method at + # delegation time so the agent can pay without the user being present. It + # must be set BEFORE cosign_intent_mandate so it is included in what the + # user's identity is bound to. + # See: https://ap2-protocol.org/specification/#412-the-intent-mandate + payment_method_reference: Optional[str] = Field( + None, + description=( + "The alias or reference to the payment method the user has" + " pre-authorised for autonomous use. Maps to the 'Chargeable Payment" + " Methods' component of AP2 spec Section 4.1.2." + ), + ) + + # AP2 EXTENSION (OneID demo): The AP2 spec describes the IntentMandate as + # "cryptographically signed by the user" for human-not-present flows and + # defines no field for a user-side identity credential (Section 4.1.2). + # In HOOTL flows this field carries the OneID JWT VC that co-signs the + # IntentMandate at the point the user delegates authority to the agent — + # binding their verified identity to the mandate that authorises autonomous + # action on their behalf. + # NOT used in HITL flows: in human-present flows the CartMandate is the + # authorisation artefact and the credential is attached there instead. + # See: https://ap2-protocol.org/specification/#412-the-intent-mandate + identity_credential: Optional[str] = Field( + None, + description=( + "AP2 EXTENSION (HOOTL only): A base64url-encoded JWT Verifiable" + " Credential issued by OneID, binding the verified identity of the" + " user to this IntentMandate at the point they delegate authority" + " to the agent for autonomous execution." + ), + ) + class CartContents(BaseModel): """The detailed contents of a cart. @@ -103,6 +141,24 @@ class CartContents(BaseModel): ) merchant_name: str = Field(..., description="The name of the merchant.") + # AP2 EXTENSION: The spec defines no mechanism for merchants to declare + # verification requirements as part of a signed cart offer. This field follows + # the precedent of user_cart_confirmation_required — a merchant-declared value + # that governs transaction behaviour — and proposes verification_requirements + # as a natural, typed extension to CartContents. Because CartContents is the + # object the merchant cryptographically signs, it is the appropriate home for + # such a declaration: the requirement travels with the signed offer and cannot + # be altered in transit. Example value: ["age_over_18"]. + # See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + verification_requirements: Optional[list[str]] = Field( + None, + description=( + "AP2 EXTENSION: Verification requirements the purchasing party must" + " satisfy before this cart can be confirmed. Proposed as a typed" + " extension to CartContents in the AP2 spec. Example: ['age_over_18']." + ), + ) + class CartMandate(BaseModel): """A cart whose contents have been digitally signed by the merchant. @@ -111,6 +167,30 @@ class CartMandate(BaseModel): """ contents: CartContents = Field(..., description="The contents of the cart.") + + # AP2 EXTENSION (OneID demo): The AP2 spec states the Cart Mandate contains + # "verifiable identities for the payer and payee" and is "cryptographically + # signed by the user, binding their identity and authorization to a specific + # transaction", but defines no field for user-side identity. + # See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + # + # This field carries the base64url-encoded OneID JWT Verifiable Credential, + # obtained when the user verified their identity at intent-confirmation time. + # The credential is scoped to the IntentMandate's expiry (intent_expiry) and + # is reused here without re-authentication. + # + # In a future AP2 revision, a field such as `payer_identity_credential` or + # `user_authorization` on CartMandate would be the appropriate home for this. + # See the AP2 roadmap: https://ap2-protocol.org/specification/#roadmap + identity_credential: Optional[str] = Field( + None, + description=( + "AP2 EXTENSION: A base64url-encoded JWT Verifiable Credential issued" + " by OneID, attesting to the verified identity of the user who" + " confirmed this cart. Scoped to the IntentMandate expiry." + ), + ) + merchant_authorization: Optional[str] = Field( None, description=(""" A base64url-encoded JSON Web Token (JWT) that digitally