Feat: CLI-level A2A authenticator for the dev server and Cloud Run deploy (Part 2/2) - #559
Open
AmaadMartin wants to merge 7 commits into
Open
Feat: CLI-level A2A authenticator for the dev server and Cloud Run deploy (Part 2/2)#559AmaadMartin wants to merge 7 commits into
AmaadMartin wants to merge 7 commits into
Conversation
added 7 commits
July 28, 2026 23:04
toA2a() fails closed unless the caller supplies an `authentication` UserBuilder, but the library shipped no ready-made implementation, so every caller had to write one (or take the `allowUnauthenticated` escape hatch). Add a shared-secret bearer-token authenticator: it validates `Authorization: Bearer <token>` with a constant-time comparison and rejects any request whose credential is missing, malformed or wrong, so the agent is never reached by an unauthenticated caller. The installed @a2a-js/sdk express handlers do not act on a user whose `isAuthenticated` is false -- they build the call context and continue -- so the builder rejects by throwing, which is the only way to actually stop the request.
The @a2a-js/sdk express handlers turn a failing UserBuilder into an HTTP 500, not a 401, so say so where callers will look for it instead of letting them infer a guarantee the SDK does not make.
The empty-token guard tested `token.trim()` but the comparison used the
raw token, so `bearerTokenUserBuilder(' tok ')` built an authenticator no
caller could satisfy: HTTP strips whitespace around header values, so the
credential never matched and the surface was silently locked shut. Trim
once, up front, and compare against that.
Also drops logger assertions that could not fail -- the module never logs
-- and folds the two blank-token cases into one parameterised test.
Second review pass. Condenses the TSDoc and helper comments, and drops two tests that could not distinguish anything: an empty credential takes the same length-mismatch path as any other wrong-length token, and the disclosure check no longer needs instanceof narrowing to read the rejection.
Third review pass. The single-caller extractor and its `undefined` sentinel forced a two-condition guard at the only call site; an empty credential can never match a token the constructor guarantees is non-empty, so the miss can be expressed directly. Also drops a test that asserted a hardcoded rejection message does not contain a token it never interpolates.
Third review pass. Seven tests with byte-identical bodies collapse into two parameterised ones, with the same branch coverage.
The dev server hardcoded `allowUnauthenticated: true` in `initA2A()` because there was no way to hand it an authenticator, and `adk deploy cloud_run --a2a` inherited that: the deployed service exposed an app-layer-unauthenticated A2A surface guarded only by whatever Cloud Run IAM the operator happened to configure. An operator who does not write TypeScript had no way to fix that. Add `--a2a_auth_token` to `adk web`, `adk api_server` and `adk deploy cloud_run`. For the two serving commands it is backed by the `ADK_A2A_AUTH_TOKEN` environment variable so the secret can be supplied out of band; when a token is configured the dev server mounts the A2A surface with `bearerTokenUserBuilder` instead of opting out of authentication. For Cloud Run the token is passed as `--update-env-vars` at deploy time and never written into the generated Dockerfile, so it never lands in an image layer. Passing a token also makes ADK the owner of the service environment: every gcloud env-var flag then raises the repo's existing conflict error rather than silently dropping the secret. `--remove-env-vars` in particular is not mutually exclusive with `--update-env-vars`, so gcloud itself would have accepted it and left an unauthenticated surface behind. Behaviour with no token is unchanged, including the loud warning, so no existing command line changes meaning; deploying `--a2a` with no token now warns as well.
7 tasks
Collaborator
Author
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
No existing issue.
This is Part 2/2 of a stacked series. It builds on Part 1/2 (branch
feat/a2a-cli-auth-token-part1), which adds thebearerTokenUserBuilderthatthis PR consumes, and should be reviewed and merged after Part 1/2. Because
the two branches are stacked, the commit range here also contains Part 1's six
core/commits; the only commit new to this PR isfeat(dev): authenticate the A2A surface from the command line, which touchesdev/exclusively.2. Or, if no issue exists, describe the change:
Problem:
toA2a()fails closed, which protects library users who call it from their owncode — but it does nothing for operators who never write TypeScript.
dev/src/server/adk_api_server.tsinitA2A()hardcodedallowUnauthenticated: trueprecisely because there was no way to hand it anauthenticator from the command line.
adk deploy cloud_run --a2athen generateda container whose
CMDisnpx adk api_server ... --a2a, so the deployedservice exposed an app-layer-unauthenticated A2A surface guarded only by
whatever Cloud Run IAM the operator happened to configure. There was no way to
authenticate that surface without writing code.
Solution:
Give the operator a flag.
AdkApiServeraccepts ana2aAuthTokenoption, falling back to theADK_A2A_AUTH_TOKENenvironment variable. When a token is present,initA2A()passesauthentication: bearerTokenUserBuilder(token)totoA2a()instead ofallowUnauthenticated: true. The builder is constructedonce, outside the per-app loop.
--a2a_auth_token <string>is added toadk web,adk api_serverandadk deploy cloud_run. It takes a required value (<string>, not[string]) so a bare flag is a parse error rather than a silently emptytoken.
adk deploy cloud_run --a2a --a2a_auth_token=<secret>forwards the token toCloud Run as
--update-env-vars ADK_A2A_AUTH_TOKEN=<secret>. The generatedDockerfile is byte-identical to today's, so the secret never lands in an image
layer.
--a2awith no token warns loudly instead of failing, so noexisting workflow breaks.
Design notes:
environment.
prepareGCloudArguments()then adds all five gcloud env-varflags to
adkManagedArgs, so a user-supplied one raises the repo's existing"conflict with ADK's automatic configuration" error. This is not defensive
padding:
--remove-env-varsis not mutually exclusive with--update-env-vars, so gcloud itself would have accepted--remove-env-vars ADK_A2A_AUTH_TOKENand left an unauthenticated servicebehind. With no token configured
adkManagedArgsis untouched, so users whopass their own
--set-env-varstoday keep working.allowUnauthenticated: authentication === undefinedrather than a baretrue. It costs nothing and keeps the opt-out tied to the absence of anauthenticator instead of depending on
resolveA2aUserBuilder's precedence.exported-but-unset
ADK_A2A_AUTH_TOKEN(or a blank line in a.env, whichthe CLI loads via dotenv) behaves like unset. Anything else is handed to
bearerTokenUserBuilder, which fails startup loudly rather than silentlydegrading to an unauthenticated surface.
fallback is a property of the serving commands only —
deployToCloudRundoesnot read
ADK_A2A_AUTH_TOKEN, and a single shared description would havepromised otherwise.
appears in the local
gcloud run deployargv, so it is visible topson thedeploying machine for the duration of the deploy. It does not reach the image
or any ADK log line. Operators who need to avoid that can set the variable on
the service out of band and deploy without the flag.
adk deploy agent_engine/reasoning_engineship throughgcloud builds submitand have no--update-env-varshook, so the flag is deliberately not added there.Testing Plan
Please describe the tests that you ran to verify your changes. This is required
for all PRs that are not small documentation or typo fixes.
Unit Tests:
I have added or updated unit tests for my change.
All unit tests pass locally.
dev/test/server/adk_api_server_test.tsboots a realAdkApiServerandsends genuine
message/sendJSON-RPC calls over HTTP — no mocks — assertingthat an authenticated call produces an agent response while a call with a
missing or wrong token is rejected without reaching the agent, that the agent
card stays publicly readable with and without a token, that
ADK_A2A_AUTH_TOKENis honoured, that an explicit option wins over it, andthat the unauthenticated default is preserved.
dev/test/cli/cli_test.tscovers--a2a_auth_tokenpassthrough forweb,api_serveranddeploy cloud_run, including that the recognised flag is notalso forwarded to gcloud as an unknown argument.
dev/test/cli/cli_deploy_cloud_run_test.tscovers the--update-env-vars ADK_A2A_AUTH_TOKEN=<token>argv pair, that neither theflag nor the variable appears when no token is given, that
--set-env-vars/--remove-env-varsconflict only when a token isconfigured, and the no-token warning.
The rejection tests assert
status >= 400rather than a specific code, becausethe status is the SDK's to choose; what matters is that the agent was not
reached.
Every line and branch added to
cli.ts,adk_api_server.tsandcli_deploy_cloud_run.tsis covered; the residual uncovered lines those filesreport are all pre-existing.
Also run on this commit:
npm run build,npm run lint,npm run format:check,npm run docs:check— all clean.Manual End-to-End (E2E) Tests:
Build the workspace (
npm run build) and point the CLI at a directorycontaining an agent (below, one that answers
pong).1. Unauthenticated (unchanged behaviour) — the core warning still fires.
2. Authenticated with
--a2a_auth_token.3. Environment-variable form.
4. Deploy plumbing, verified without actually deploying (a stub
gcloudonPATHrecorded its argv and copied the generated Dockerfile out of the tempfolder before cleanup).
$ adk deploy cloud_run ./agents --project=example-project --region=us-central1 \ --a2a --a2a_auth_token=hunter2 # recorded gcloud argv: run deploy adk-default-service-name --source /tmp/cloud_run_deploy_src/<uuid> --project example-project --region us-central1 --port 8000 --verbosity info --update-env-vars ADK_A2A_AUTH_TOKEN=hunter2 --labels created-by=adk # the generated Dockerfile mentions neither the secret, the variable, nor the flag: $ grep -c 'hunter2\|ADK_A2A_AUTH_TOKEN\|a2a_auth_token' Dockerfile 0 $ grep '^CMD' Dockerfile CMD npx adk api_server /app/agents/agents --port=8000 --host=0.0.0.0 --log_level=info --a2aChecklist
Additional context
Part 1/2 (
feat/a2a-cli-auth-token-part1) is the dependent change: it addsbearerTokenUserBuilderincore/. It has not been merged yet, which is whythe last checklist item above is unchecked.