Skip to content

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
google:mainfrom
AmaadMartin:feat/a2a-cli-auth-token-part2
Open

Feat: CLI-level A2A authenticator for the dev server and Cloud Run deploy (Part 2/2)#559
AmaadMartin wants to merge 7 commits into
google:mainfrom
AmaadMartin:feat/a2a-cli-auth-token-part2

Conversation

@AmaadMartin

Copy link
Copy Markdown
Collaborator

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 the bearerTokenUserBuilder that
this 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 is feat(dev): authenticate the A2A surface from the command line, which touches dev/ exclusively.

2. Or, if no issue exists, describe the change:

Problem:
toA2a() fails closed, which protects library users who call it from their own
code — but it does nothing for operators who never write TypeScript.
dev/src/server/adk_api_server.ts initA2A() hardcoded
allowUnauthenticated: true precisely because there was no way to hand it an
authenticator from the command line. adk deploy cloud_run --a2a then generated
a container whose CMD is npx adk api_server ... --a2a, so the deployed
service 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.

  • AdkApiServer accepts an a2aAuthToken option, falling back to the
    ADK_A2A_AUTH_TOKEN environment variable. When a token is present,
    initA2A() passes authentication: bearerTokenUserBuilder(token) to
    toA2a() instead of allowUnauthenticated: true. The builder is constructed
    once, outside the per-app loop.
  • --a2a_auth_token <string> is added to adk web, adk api_server and
    adk deploy cloud_run. It takes a required value (<string>, not
    [string]) so a bare flag is a parse error rather than a silently empty
    token.
  • adk deploy cloud_run --a2a --a2a_auth_token=<secret> forwards the token to
    Cloud Run as --update-env-vars ADK_A2A_AUTH_TOKEN=<secret>. The generated
    Dockerfile is byte-identical to today's, so the secret never lands in an image
    layer.
  • Deploying --a2a with no token warns loudly instead of failing, so no
    existing workflow breaks.

Design notes:

  • Passing a token makes ADK the owner of the deployed service's
    environment.
    prepareGCloudArguments() then adds all five gcloud env-var
    flags 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-vars is not mutually exclusive with
    --update-env-vars, so gcloud itself would have accepted
    --remove-env-vars ADK_A2A_AUTH_TOKEN and left an unauthenticated service
    behind. With no token configured adkManagedArgs is untouched, so users who
    pass their own --set-env-vars today keep working.
  • allowUnauthenticated: authentication === undefined rather than a bare
    true. It costs nothing and keeps the opt-out tied to the absence of an
    authenticator instead of depending on resolveA2aUserBuilder's precedence.
  • An empty option or environment variable means "no token", so an
    exported-but-unset ADK_A2A_AUTH_TOKEN (or a blank line in a .env, which
    the CLI loads via dotenv) behaves like unset. Anything else is handed to
    bearerTokenUserBuilder, which fails startup loudly rather than silently
    degrading to an unauthenticated surface.
  • The deploy flag gets its own help text, because the environment-variable
    fallback is a property of the serving commands only — deployToCloudRun does
    not read ADK_A2A_AUTH_TOKEN, and a single shared description would have
    promised otherwise.
  • Known limitation, unchanged from any other gcloud env-var usage: the token
    appears in the local gcloud run deploy argv, so it is visible to ps on the
    deploying 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.
  • Out of scope, as agreed during design: adk deploy agent_engine /
    reasoning_engine ship through gcloud builds submit and have no
    --update-env-vars hook, 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.ts boots a real AdkApiServer and
    sends genuine message/send JSON-RPC calls over HTTP — no mocks — asserting
    that 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_TOKEN is honoured, that an explicit option wins over it, and
    that the unauthenticated default is preserved.

  • dev/test/cli/cli_test.ts covers --a2a_auth_token passthrough for web,
    api_server and deploy cloud_run, including that the recognised flag is not
    also forwarded to gcloud as an unknown argument.

  • dev/test/cli/cli_deploy_cloud_run_test.ts covers the
    --update-env-vars ADK_A2A_AUTH_TOKEN=<token> argv pair, that neither the
    flag nor the variable appears when no token is given, that
    --set-env-vars/--remove-env-vars conflict only when a token is
    configured, and the no-token warning.

The rejection tests assert status >= 400 rather than a specific code, because
the status is the SDK's to choose; what matters is that the agent was not
reached.

$ npx vitest run --project unit:core --project unit:dev \
    core/test/a2a/auth_test.ts core/test/a2a/agent_to_a2a_test.ts \
    dev/test/server/adk_api_server_test.ts dev/test/cli/cli_test.ts \
    dev/test/cli/cli_deploy_cloud_run_test.ts
 ✓ |unit:core| core/test/a2a/auth_test.ts (9 tests)
 ✓ |unit:core| core/test/a2a/agent_to_a2a_test.ts (8 tests)
 ✓ |unit:dev| dev/test/cli/cli_deploy_cloud_run_test.ts (18 tests)
 ✓ |unit:dev| dev/test/cli/cli_test.ts (24 tests)
 ✓ |unit:dev| dev/test/server/adk_api_server_test.ts (51 tests)
 Test Files  5 passed (5)
      Tests  110 passed (110)

Every line and branch added to cli.ts, adk_api_server.ts and
cli_deploy_cloud_run.ts is covered; the residual uncovered lines those files
report 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.

Note: the commands above were run locally against the exact commit pushed
to this branch.

Manual End-to-End (E2E) Tests:

Build the workspace (npm run build) and point the CLI at a directory
containing an agent (below, one that answers pong).

1. Unauthenticated (unchanged behaviour) — the core warning still fires.

$ node dev/dist/esm/cli_entrypoint.js api_server ./agents --a2a --port 8123
WARN: [ADK] SECURITY WARNING: Mounting the A2A server WITHOUT authentication because
`allowUnauthenticated: true` was set. ...

$ PAYLOAD='{"jsonrpc":"2.0","id":"1","method":"message/send","params":{"message":{"kind":"message","messageId":"m1","role":"user","parts":[{"kind":"text","text":"ping"}]}}}'
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' -X POST localhost:8123/a2a/my_agent/jsonrpc \
    -H 'Content-Type: application/json' -d "$PAYLOAD"
HTTP 200

2. Authenticated with --a2a_auth_token.

$ node dev/dist/esm/cli_entrypoint.js api_server ./agents --a2a --a2a_auth_token=hunter2 --port 8124
# (no SECURITY WARNING)

$ curl -s -w 'HTTP %{http_code}\n' -X POST localhost:8124/a2a/my_agent/jsonrpc \
    -H 'Content-Type: application/json' -d "$PAYLOAD"
{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"General processing error."}}
HTTP 500

$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' -X POST localhost:8124/a2a/my_agent/jsonrpc \
    -H 'Content-Type: application/json' -H 'Authorization: Bearer wrong' -d "$PAYLOAD"
HTTP 500

$ curl -s -X POST localhost:8124/a2a/my_agent/jsonrpc \
    -H 'Content-Type: application/json' -H 'Authorization: Bearer hunter2' -d "$PAYLOAD"
{"jsonrpc":"2.0","id":"1","result":{"kind":"task","id":"a51d75ff-...","history":[...]}}
HTTP 200

# Agent card stays public, by design.
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' localhost:8124/a2a/my_agent/.well-known/agent-card.json
HTTP 200

# The token is never logged.
$ grep -c hunter2 server.log
0

3. Environment-variable form.

$ ADK_A2A_AUTH_TOKEN=envsecret node dev/dist/esm/cli_entrypoint.js api_server ./agents --a2a --port 8125
# (no SECURITY WARNING)
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' -X POST localhost:8125/a2a/my_agent/jsonrpc \
    -H 'Content-Type: application/json' -d "$PAYLOAD"
HTTP 500
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' -X POST localhost:8125/a2a/my_agent/jsonrpc \
    -H 'Content-Type: application/json' -H 'Authorization: Bearer envsecret' -d "$PAYLOAD"
HTTP 200

4. Deploy plumbing, verified without actually deploying (a stub gcloud on
PATH recorded its argv and copied the generated Dockerfile out of the temp
folder 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 --a2a
# --a2a with no token warns and touches no env-var flags:
$ adk deploy cloud_run ./agents --project=example-project --region=us-central1 --a2a
SECURITY WARNING: deploying the A2A surface WITHOUT authentication, so any caller that can
reach the service can invoke the agent and its tools. Pass --a2a_auth_token=<secret> to
require a bearer credential.
$ grep -c env-vars gcloud_argv.txt
0

# a user env-var flag alongside a token is refused, including the one gcloud would accept:
$ adk deploy cloud_run ./agents --project=example-project --region=us-central1 \
    --a2a --a2a_auth_token=hunter2 --remove-env-vars=FOO
[ADK CLI] Error deploying agent: The argument(s) --remove-env-vars conflict with ADK's
automatic configuration. ADK manages these arguments itself, so please remove them from
your command.

# ... but with no token, a user env-var flag is passed through as before:
$ adk deploy cloud_run ./agents --project=example-project --region=us-central1 \
    --set-env-vars=FOO=bar
$ grep -c 'FOO=bar' gcloud_argv.txt
1

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

Part 1/2 (feat/a2a-cli-auth-token-part1) is the dependent change: it adds
bearerTokenUserBuilder in core/. It has not been merged yet, which is why
the last checklist item above is unchecked.

Amaad Martin 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.
@AmaadMartin

Copy link
Copy Markdown
Collaborator Author

Cross-link: Part 1/2 of this series is now open upstream as #562 (feat/a2a-cli-auth-token-part1). This PR (Part 2/2) builds on it and should be reviewed/merged after #562.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant