Skip to content

feat(auth): implement JWT-based principal authentication#46

Open
zeroasterisk wants to merge 7 commits into
actioncard:mainfrom
zeroasterisk:feature/principal-jwt-auth
Open

feat(auth): implement JWT-based principal authentication#46
zeroasterisk wants to merge 7 commits into
actioncard:mainfrom
zeroasterisk:feature/principal-jwt-auth

Conversation

@zeroasterisk

Copy link
Copy Markdown
Contributor

Implements inbound credential verification to derive stable principal/User ID instead of defaulting unknown senders to 'test'.

Changes

  • Add A2A.Plug.JWTVerifier for JWT token verification with HMAC/RSA support
  • Add A2A.Plug.Auth for credential extraction and authentication middleware
  • Add AgentmsgElixirWeb.A2AController with JWT verification for Phoenix apps
  • Derive stable principal IDs from JWT claims instead of defaulting to 'test'
  • Support configurable JWT verification with JWKS, issuer, audience validation
  • Include comprehensive documentation and examples for JWT auth configuration

Key Features

  • JWT Verification: Supports HS256 and RS256 signature algorithms
  • Claims Validation: Validates exp, nbf, iat, issuer, audience, and custom claims
  • Principal Identity: Extracts stable principal ID from JWT sub and principal_type claims
  • Phoenix Integration: Ready-to-use controller with auth middleware
  • Comprehensive Docs: Full configuration examples and usage patterns

Security

  • Constant-time signature comparison to prevent timing attacks
  • Configurable clock skew tolerance for time-based claims
  • Required claims validation with customizable requirements
  • Proper error handling without information leakage

Resolves the requirement to implement inbound credential verification for stable principal identification.

- Add A2A.Plug.JWTVerifier for JWT token verification with HMAC/RSA support
- Add A2A.Plug.Auth for credential extraction and authentication middleware
- Add AgentmsgElixirWeb.A2AController with JWT verification for Phoenix apps
- Derive stable principal IDs from JWT claims instead of defaulting to 'test'
- Support configurable JWT verification with JWKS, issuer, audience validation
- Include comprehensive documentation and examples for JWT auth configuration
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

TCK 1.0-dev Compatibility Results (experimental)

This run is informational — failures do not block CI.

             A2A TCK Compatibility Report              
═══════════════════════════════════════════════════════
SUT: http://localhost:9999
Timestamp: 2026-06-11T13:49:21.564809+00:00

OVERALL COMPATIBILITY: 71.6%

┌─────────────┬────────┬────────┬─────────┬───────┐
│ Level       │ Passed │ Failed │ Skipped │ Total │
├─────────────┼────────┼────────┼─────────┼───────┤
│ MUST        │     44 │     35 │      35 │   114 │
│ SHOULD      │      2 │      9 │       0 │    11 │
│ MAY         │      2 │      2 │       0 │     4 │
└─────────────┴────────┴────────┴─────────┴───────┘

BY TRANSPORT:
  agent_card:    8/10 ⚠
  grpc:          0/72 (72 skipped) ✓
  jsonrpc:       50/100 (30 skipped) ⚠
  http_json:     3/83 (80 skipped) ✓

FAILED REQUIREMENTS:
  ✗ CARD-CACHE-002 (agent_card): Agent Card response should include an ETag header
  ✗ CARD-CACHE-003 (agent_card): Agent Card response may include a Last-Modified header
  ✗ DM-ART-001 (): Response contains no artifacts
  ✗ DM-MSG-001 (): Expected a Message response, but got a Task or no payload
  ✗ JSONRPC-SSE-002 (): Error code mismatch: expected ContentTypeNotSupportedError (-32005), got ParseError (-32700)
  ✗ JSONRPC-ERR-003 (): error.data is absent — A2A errors MUST include ErrorInfo in data array
  ✗ CORE-MULTI-004 (jsonrpc): Expected error code -32001 (TaskNotFoundError), got -32603
  ✗ CORE-HIST-001 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-HIST-002 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-HIST-003 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-HIST-004 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-HIST-005 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-HIST-006 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-GET-001 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_COMPLETED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-CANCEL-001 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-CANCEL-002 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_COMPLETED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-SEND-002 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_COMPLETED' but got 'TASK_STATE_WORKING'")
  ✗ CORE-MULTI-005 (jsonrpc): ('/home/runner/work/a2a-elixir/a2a-elixir/.tck-v1/tests/compatibility/_task_helpers.py', 98, "Skipped: Expected task state 'TASK_STATE_INPUT_REQUIRED' but got 'TASK_STATE_WORKING'")

The create_jwt_token/2 helper had a quadruple-backslash (\\\\) where
Elixir default-argument syntax needs a double-backslash (\\), so the file
failed to parse. This broke 'mix format --check-formatted' (Quality job)
and made all four Test matrix jobs fail to compile.

Verified locally: mix format --check-formatted exit 0; mix test = 512
tests + 2 doctests, 0 failures.
Formatting-only (line wrapping to 100 chars, comment placement, trailing
whitespace). No logic change. Satisfies the Quality job mix format gate.

Verified: mix format --check-formatted exit 0; mix test = 512 tests +
2 doctests, 0 failures.
@maxekman

Copy link
Copy Markdown
Contributor

See if https://hex.pm/packages/joken could be used instead of rolling your own JWT verification.

Replaces hand-rolled HMAC/base64/constant-time-compare crypto in
A2A.Plug.JWTVerifier with Joken (per maintainer suggestion on actioncard#46).

- Signature verification via Joken.Signer + Joken.verify (HS256)
- Header/algorithm + claim validation kept local for descriptive errors
- Module now guarded on both :plug and :joken (optional deps)
- jose pinned <1.11.11 (1.11.11+ needs OTP 26 dynamic(); CI targets OTP 25)

Public API (new/1, verify/2) and all 18 existing tests unchanged.
@zeroasterisk

Copy link
Copy Markdown
Contributor Author

Done — swapped the hand-rolled JWT crypto for Joken in 202f15c. Signature verification now goes through Joken.Signer/Joken.verify (HS256); the manual HMAC, base64url decoding, and constant-time compare are gone. Header/algorithm and claim checks stay local so error reasons remain descriptive. Public API (new/1, verify/2) is unchanged and all 18 existing tests still pass.

Two notes worth flagging:

  • The module is now guarded on both :plug and :joken (both optional), consistent with the other optional-dep modules.
  • I pinned jose to < 1.11.11 — 1.11.11+ uses the OTP 26 dynamic() type and won't compile on the OTP 25 CI target. Happy to drop the pin if/when OTP 25 support is dropped.

RFC 7519 NumericDate MAY contain a fractional component. Widen the
exp/nbf guards from is_integer to is_number so float timestamps are
honored instead of rejected as malformed. Also corrects the moduledoc
feature list (dropped the unvalidated 'iat' claim).
Adds alg:none / alg:None rejection, empty-signature, stripped-signature,
and wrong-secret cases, plus fractional-exp acceptance, non-numeric-exp
rejection, and audience-as-list match/mismatch.
The example claimed JWKS-based verification and carried jwks_url/cache_ttl
config keys the verifier never reads, producing a non-functional secret:nil
verifier. Switch the example to a working HS256 shared-secret config and
guard the module on Joken (with an accurate fallback message).
@zeroasterisk

Copy link
Copy Markdown
Contributor Author

Did a thorough review/hardening pass on top of the Joken refactor (3 follow-up commits, all CI green across the 1.17/1.18 × OTP 25/27 matrix):

Security — added explicit tests proving forged tokens are rejected: alg:none/alg:None (caught at algorithm check before any signer is built), empty signature, stripped signature, and wrong-secret. Fuzzed verify/2 with 14 malformed inputs (empty, dots-only, 100K chars, unicode, non-JSON header) — all return {:error, _}, never raise.

Correctnessexp/nbf now use is_number instead of is_integer: RFC 7519 NumericDate may carry a fractional component, so a float exp was being wrongly rejected as malformed. Added tests for fractional-exp accept, non-numeric-exp reject, and audience-as-list match/mismatch.

Honesty fixes — the moduledoc feature list claimed an iat validation that doesn't exist (dropped it). More importantly, the example A2AController advertised JWKS-based verification and carried jwks_url/cache_ttl config keys the verifier never reads — that config would have built a secret: nil verifier and rejected every token. Switched the example to a working HS256 shared-secret config and guarded the module on Joken with an accurate fallback message.

Net: signature verification fully delegated to Joken (no hand-rolled crypto), HS256-only and the docs now say so, +9 tests. Happy to split any of these out if you'd rather they land separately.

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.

2 participants