a2a-handler 0.1.16__tar.gz → 0.1.18__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/PKG-INFO +41 -48
- a2a_handler-0.1.18/README.md +76 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/pyproject.toml +22 -5
- a2a_handler-0.1.18/src/a2a_handler/auth.py +284 -0
- a2a_handler-0.1.18/src/a2a_handler/cli/__init__.py +173 -0
- a2a_handler-0.1.18/src/a2a_handler/cli/_helpers.py +207 -0
- a2a_handler-0.1.18/src/a2a_handler/cli/card.py +183 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/cli/mcp.py +7 -1
- a2a_handler-0.1.18/src/a2a_handler/cli/message.py +356 -0
- a2a_handler-0.1.18/src/a2a_handler/cli/schema.py +122 -0
- a2a_handler-0.1.18/src/a2a_handler/cli/server.py +669 -0
- a2a_handler-0.1.18/src/a2a_handler/cli/session.py +117 -0
- a2a_handler-0.1.18/src/a2a_handler/cli/task.py +374 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/common/__init__.py +2 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/common/config.py +3 -1
- a2a_handler-0.1.18/src/a2a_handler/common/dotenv.py +18 -0
- a2a_handler-0.1.18/src/a2a_handler/common/input_validation.py +189 -0
- a2a_handler-0.1.18/src/a2a_handler/common/output.py +66 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/mcp/server.py +261 -141
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/server/agent.py +2 -2
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/server/app.py +34 -2
- a2a_handler-0.1.18/src/a2a_handler/servers.py +614 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/service.py +195 -134
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/session.py +114 -71
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/__init__.py +3 -1
- a2a_handler-0.1.18/src/a2a_handler/tui/app.py +385 -0
- a2a_handler-0.1.18/src/a2a_handler/tui/app.tcss +845 -0
- a2a_handler-0.1.18/src/a2a_handler/tui/commands/__init__.py +6 -0
- a2a_handler-0.1.18/src/a2a_handler/tui/commands/palette.py +91 -0
- a2a_handler-0.1.18/src/a2a_handler/tui/commands/screens.py +87 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/__init__.py +3 -4
- a2a_handler-0.1.18/src/a2a_handler/tui/components/auth.py +251 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/card.py +11 -8
- a2a_handler-0.1.18/src/a2a_handler/tui/components/headers.py +58 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/input.py +14 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/messages.py +83 -88
- a2a_handler-0.1.18/src/a2a_handler/tui/server/__init__.py +6 -0
- a2a_handler-0.1.18/src/a2a_handler/tui/server/save.py +188 -0
- a2a_handler-0.1.18/src/a2a_handler/tui/server/session.py +43 -0
- a2a_handler-0.1.18/src/a2a_handler/tui/server/tab.py +870 -0
- a2a_handler-0.1.18/src/a2a_handler/tui/server/tabs.py +181 -0
- a2a_handler-0.1.18/src/a2a_handler/tui/server/types.py +85 -0
- a2a_handler-0.1.18/src/a2a_handler/tui/server/views.py +253 -0
- a2a_handler-0.1.18/src/a2a_handler/tui/server/workspace.py +74 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/validation.py +19 -1
- a2a_handler-0.1.16/README.md +0 -83
- a2a_handler-0.1.16/src/a2a_handler/auth.py +0 -79
- a2a_handler-0.1.16/src/a2a_handler/cli/__init__.py +0 -101
- a2a_handler-0.1.16/src/a2a_handler/cli/_config.py +0 -102
- a2a_handler-0.1.16/src/a2a_handler/cli/_helpers.py +0 -54
- a2a_handler-0.1.16/src/a2a_handler/cli/auth.py +0 -89
- a2a_handler-0.1.16/src/a2a_handler/cli/card.py +0 -101
- a2a_handler-0.1.16/src/a2a_handler/cli/message.py +0 -203
- a2a_handler-0.1.16/src/a2a_handler/cli/server.py +0 -59
- a2a_handler-0.1.16/src/a2a_handler/cli/session.py +0 -62
- a2a_handler-0.1.16/src/a2a_handler/cli/task.py +0 -269
- a2a_handler-0.1.16/src/a2a_handler/common/output.py +0 -155
- a2a_handler-0.1.16/src/a2a_handler/tui/app.py +0 -296
- a2a_handler-0.1.16/src/a2a_handler/tui/app.tcss +0 -457
- a2a_handler-0.1.16/src/a2a_handler/tui/components/auth.py +0 -99
- a2a_handler-0.1.16/src/a2a_handler/tui/components/contact.py +0 -168
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/LICENSE +0 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/__init__.py +0 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/common/logging.py +0 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/mcp/__init__.py +0 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/server/__init__.py +0 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/server/card.py +0 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/server/ollama.py +0 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/artifacts.py +0 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/logs.py +0 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/tui/components/tasks.py +0 -0
- {a2a_handler-0.1.16 → a2a_handler-0.1.18}/src/a2a_handler/webhook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: a2a-handler
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.18
|
|
4
4
|
Summary: An A2A Protocol client TUI and CLI.
|
|
5
5
|
Keywords: a2a,a2a-protocol,agent,agent-to-agent,ai,cli,tui
|
|
6
6
|
Author: Al Duncanson
|
|
@@ -18,10 +18,9 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
20
|
Classifier: Typing :: Typed
|
|
21
|
-
Requires-Dist: a2a-sdk>=0.3.
|
|
21
|
+
Requires-Dist: a2a-sdk>=0.3.24
|
|
22
22
|
Requires-Dist: click>=8.0.0
|
|
23
|
-
Requires-Dist:
|
|
24
|
-
Requires-Dist: google-adk>=0.5.0
|
|
23
|
+
Requires-Dist: google-adk>=1.26.0
|
|
25
24
|
Requires-Dist: httpx>=0.27.0
|
|
26
25
|
Requires-Dist: litellm>=1.0.0
|
|
27
26
|
Requires-Dist: python-dotenv>=1.0.0
|
|
@@ -31,10 +30,11 @@ Requires-Dist: uvicorn>=0.30.0
|
|
|
31
30
|
Requires-Dist: textual-serve>=1.1.0
|
|
32
31
|
Requires-Dist: mcp[cli]>=1.2.0
|
|
33
32
|
Requires-Dist: truststore>=0.9.0
|
|
33
|
+
Requires-Dist: platformdirs>=4.0.0
|
|
34
34
|
Requires-Python: >=3.11
|
|
35
35
|
Project-URL: Homepage, https://github.com/alDuncanson/handler
|
|
36
36
|
Project-URL: Repository, https://github.com/alDuncanson/handler
|
|
37
|
-
Project-URL: Documentation, https://github.com/alDuncanson/handler
|
|
37
|
+
Project-URL: Documentation, https://github.com/alDuncanson/handler/tree/main/docs
|
|
38
38
|
Project-URL: Issues, https://github.com/alDuncanson/handler/issues
|
|
39
39
|
Project-URL: Changelog, https://github.com/alDuncanson/handler/releases
|
|
40
40
|
Description-Content-Type: text/markdown
|
|
@@ -51,74 +51,67 @@ Description-Content-Type: text/markdown
|
|
|
51
51
|
|
|
52
52
|

|
|
53
53
|
|
|
54
|
-
Handler is an open-source [A2A
|
|
55
|
-
client and developer toolkit.
|
|
54
|
+
Handler is an open-source [A2A protocol](https://github.com/a2aproject/A2A) client for your terminal.
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
implementation, push notification support, and agent card validation. Whether
|
|
60
|
-
you're building agents, integrating with existing ones, or exploring
|
|
61
|
-
agent-to-agent communication, Handler gives you the observability and control
|
|
62
|
-
you need from your terminal.
|
|
56
|
+
Use the TUI or CLI to send messages, manage tasks, and inspect agent cards.
|
|
57
|
+
An included MCP server lets your AI assistant talk to A2A agents too.
|
|
63
58
|
|
|
64
|
-
##
|
|
59
|
+
## Install
|
|
65
60
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
explore their capabilities. If you want your AI assistant to communicate with
|
|
70
|
-
other agents, Handler's MCP server bridges that gap. And if you're just curious
|
|
71
|
-
about agent-to-agent communication, Handler is a great place to start.
|
|
72
|
-
|
|
73
|
-
## Get Started
|
|
74
|
-
|
|
75
|
-
### Install
|
|
61
|
+
```bash
|
|
62
|
+
uv tool install a2a-handler
|
|
63
|
+
```
|
|
76
64
|
|
|
77
|
-
|
|
65
|
+
Or with [pipx](https://pipx.pypa.io/) / pip:
|
|
78
66
|
|
|
79
67
|
```bash
|
|
80
|
-
|
|
68
|
+
pipx install a2a-handler # or: pip install a2a-handler
|
|
81
69
|
```
|
|
82
70
|
|
|
83
|
-
|
|
71
|
+
## Quick Start
|
|
84
72
|
|
|
85
73
|
```bash
|
|
86
|
-
|
|
74
|
+
handler tui # launch the interactive TUI
|
|
75
|
+
handler message send URL "hello" # send a message from the CLI
|
|
76
|
+
handler card get URL # fetch an agent card
|
|
77
|
+
handler mcp # start the MCP server
|
|
78
|
+
handler server run agent # run the bundled reference agent
|
|
87
79
|
```
|
|
88
80
|
|
|
89
|
-
|
|
81
|
+
## Run Without Installing
|
|
90
82
|
|
|
91
83
|
```bash
|
|
92
|
-
|
|
84
|
+
uvx --from a2a-handler handler # or: pipx run a2a-handler
|
|
93
85
|
```
|
|
94
86
|
|
|
95
|
-
|
|
87
|
+
## Servers
|
|
96
88
|
|
|
97
|
-
|
|
89
|
+
Handler supports named servers in `$XDG_CONFIG_HOME/handler/servers.toml`
|
|
90
|
+
(global) and `.handler/servers.toml` (repo-local).
|
|
98
91
|
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
|
|
92
|
+
```toml
|
|
93
|
+
version = 1
|
|
94
|
+
|
|
95
|
+
[servers.local]
|
|
96
|
+
url = "http://localhost:8000"
|
|
102
97
|
|
|
103
|
-
|
|
104
|
-
|
|
98
|
+
[servers.local.auth]
|
|
99
|
+
type = "bearer"
|
|
100
|
+
env = "HANDLER_LOCAL_TOKEN"
|
|
105
101
|
```
|
|
106
102
|
|
|
107
|
-
|
|
103
|
+
See [docs/](docs) for the full auth reference covering bearer, API key, mTLS,
|
|
104
|
+
and OAuth2.
|
|
105
|
+
|
|
106
|
+
## Development
|
|
108
107
|
|
|
109
|
-
A [
|
|
110
|
-
|
|
111
|
-
[Nix](https://zero-to-nix.com/concepts/nix/):
|
|
108
|
+
A [hermetic](https://zero-to-nix.com/concepts/hermeticity/) dev environment
|
|
109
|
+
is available via [Nix](https://zero-to-nix.com/concepts/nix/):
|
|
112
110
|
|
|
113
111
|
```bash
|
|
114
112
|
nix develop
|
|
115
113
|
```
|
|
116
114
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
For usage documentation, see the
|
|
120
|
-
[Handler docs](https://alduncanson.github.io/Handler/).
|
|
121
|
-
|
|
122
|
-
## Contributing
|
|
115
|
+
The Mintlify docs source lives in `docs/`.
|
|
123
116
|
|
|
124
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
117
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Handler
|
|
2
|
+
|
|
3
|
+
[](https://github.com/alDuncanson/handler/actions/workflows/ci.yml)
|
|
4
|
+
[](https://a2a-protocol.org/latest/)
|
|
5
|
+
[](https://pypi.org/project/a2a-handler/)
|
|
6
|
+
[](https://pypi.org/project/a2a-handler/)
|
|
7
|
+
[](https://pypi.org/project/a2a-handler/)
|
|
8
|
+
[](https://pepy.tech/projects/a2a-handler)
|
|
9
|
+
[](https://github.com/alDuncanson/handler/stargazers)
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
Handler is an open-source [A2A protocol](https://github.com/a2aproject/A2A) client for your terminal.
|
|
14
|
+
|
|
15
|
+
Use the TUI or CLI to send messages, manage tasks, and inspect agent cards.
|
|
16
|
+
An included MCP server lets your AI assistant talk to A2A agents too.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv tool install a2a-handler
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or with [pipx](https://pipx.pypa.io/) / pip:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pipx install a2a-handler # or: pip install a2a-handler
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
handler tui # launch the interactive TUI
|
|
34
|
+
handler message send URL "hello" # send a message from the CLI
|
|
35
|
+
handler card get URL # fetch an agent card
|
|
36
|
+
handler mcp # start the MCP server
|
|
37
|
+
handler server run agent # run the bundled reference agent
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Run Without Installing
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uvx --from a2a-handler handler # or: pipx run a2a-handler
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Servers
|
|
47
|
+
|
|
48
|
+
Handler supports named servers in `$XDG_CONFIG_HOME/handler/servers.toml`
|
|
49
|
+
(global) and `.handler/servers.toml` (repo-local).
|
|
50
|
+
|
|
51
|
+
```toml
|
|
52
|
+
version = 1
|
|
53
|
+
|
|
54
|
+
[servers.local]
|
|
55
|
+
url = "http://localhost:8000"
|
|
56
|
+
|
|
57
|
+
[servers.local.auth]
|
|
58
|
+
type = "bearer"
|
|
59
|
+
env = "HANDLER_LOCAL_TOKEN"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
See [docs/](docs) for the full auth reference covering bearer, API key, mTLS,
|
|
63
|
+
and OAuth2.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
A [hermetic](https://zero-to-nix.com/concepts/hermeticity/) dev environment
|
|
68
|
+
is available via [Nix](https://zero-to-nix.com/concepts/nix/):
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
nix develop
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The Mintlify docs source lives in `docs/`.
|
|
75
|
+
|
|
76
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "a2a-handler"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.18"
|
|
4
4
|
description = "An A2A Protocol client TUI and CLI."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -32,10 +32,9 @@ classifiers = [
|
|
|
32
32
|
"Typing :: Typed",
|
|
33
33
|
]
|
|
34
34
|
dependencies = [
|
|
35
|
-
"a2a-sdk>=0.3.
|
|
35
|
+
"a2a-sdk>=0.3.24",
|
|
36
36
|
"click>=8.0.0",
|
|
37
|
-
"
|
|
38
|
-
"google-adk>=0.5.0",
|
|
37
|
+
"google-adk>=1.26.0",
|
|
39
38
|
"httpx>=0.27.0",
|
|
40
39
|
"litellm>=1.0.0",
|
|
41
40
|
"python-dotenv>=1.0.0",
|
|
@@ -45,6 +44,7 @@ dependencies = [
|
|
|
45
44
|
"textual-serve>=1.1.0",
|
|
46
45
|
"mcp[cli]>=1.2.0",
|
|
47
46
|
"truststore>=0.9.0",
|
|
47
|
+
"platformdirs>=4.0.0",
|
|
48
48
|
]
|
|
49
49
|
|
|
50
50
|
[dependency-groups]
|
|
@@ -54,12 +54,14 @@ dev = [
|
|
|
54
54
|
"pytest>=8.0.0",
|
|
55
55
|
"pytest-asyncio>=0.23.0",
|
|
56
56
|
"ty>=0.0.1a27",
|
|
57
|
+
"pytest-cov>=7.1.0",
|
|
58
|
+
"pytest-textual-snapshot>=1.0.0",
|
|
57
59
|
]
|
|
58
60
|
|
|
59
61
|
[project.urls]
|
|
60
62
|
Homepage = "https://github.com/alDuncanson/handler"
|
|
61
63
|
Repository = "https://github.com/alDuncanson/handler"
|
|
62
|
-
Documentation = "https://github.com/alDuncanson/handler
|
|
64
|
+
Documentation = "https://github.com/alDuncanson/handler/tree/main/docs"
|
|
63
65
|
Issues = "https://github.com/alDuncanson/handler/issues"
|
|
64
66
|
Changelog = "https://github.com/alDuncanson/handler/releases"
|
|
65
67
|
|
|
@@ -73,5 +75,20 @@ build-backend = "uv_build"
|
|
|
73
75
|
[tool.uv.build-backend]
|
|
74
76
|
module-root = "src"
|
|
75
77
|
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
asyncio_mode = "auto"
|
|
80
|
+
|
|
76
81
|
[tool.ruff.lint.per-file-ignores]
|
|
77
82
|
"src/a2a_handler/cli/__init__.py" = ["E402"]
|
|
83
|
+
|
|
84
|
+
[tool.coverage.run]
|
|
85
|
+
source = ["a2a_handler"]
|
|
86
|
+
|
|
87
|
+
[tool.coverage.report]
|
|
88
|
+
show_missing = true
|
|
89
|
+
skip_empty = true
|
|
90
|
+
exclude_lines = [
|
|
91
|
+
"pragma: no cover",
|
|
92
|
+
"if TYPE_CHECKING:",
|
|
93
|
+
"if __name__ == .__main__.",
|
|
94
|
+
]
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Authentication support for A2A protocol.
|
|
2
|
+
|
|
3
|
+
Handles credential storage and HTTP authentication header generation.
|
|
4
|
+
Supports API key, HTTP bearer, mTLS (mutual TLS), and OAuth2 client credentials
|
|
5
|
+
authentication schemes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ssl
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
from a2a_handler.common.input_validation import (
|
|
18
|
+
check_key_file_permissions,
|
|
19
|
+
validate_header_name,
|
|
20
|
+
validate_token_url,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_TOKEN_EXPIRY_MARGIN = 30 # seconds before expiry to trigger refresh
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthType(str, Enum):
|
|
30
|
+
"""Supported authentication types."""
|
|
31
|
+
|
|
32
|
+
API_KEY = "api_key"
|
|
33
|
+
BEARER = "bearer"
|
|
34
|
+
MTLS = "mtls"
|
|
35
|
+
OAUTH2 = "oauth2"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class AuthCredentials:
|
|
40
|
+
"""Authentication credentials for an agent.
|
|
41
|
+
|
|
42
|
+
Stores the credential value and metadata about how to apply it.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
auth_type: AuthType
|
|
46
|
+
value: str = ""
|
|
47
|
+
header_name: str | None = None # For API key: custom header name
|
|
48
|
+
cert_path: str | None = None # For mTLS: client certificate path
|
|
49
|
+
key_path: str | None = None # For mTLS: client private key path
|
|
50
|
+
ca_cert_path: str | None = None # For mTLS: CA certificate path
|
|
51
|
+
token_url: str | None = None # For OAuth2: token endpoint
|
|
52
|
+
client_id: str | None = None # For OAuth2: client ID
|
|
53
|
+
client_secret: str | None = None # For OAuth2: client secret
|
|
54
|
+
scopes: list[str] | None = None # For OAuth2: optional scopes
|
|
55
|
+
custom_headers: dict[str, str] | None = None # Additional headers for any auth type
|
|
56
|
+
_token_expires_at: float | None = field(default=None, repr=False)
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
"""Redacted repr to prevent secret leakage in logs and tracebacks."""
|
|
60
|
+
redacted = {
|
|
61
|
+
"auth_type": self.auth_type.value,
|
|
62
|
+
"header_name": self.header_name,
|
|
63
|
+
"cert_path": self.cert_path,
|
|
64
|
+
"key_path": self.key_path,
|
|
65
|
+
"ca_cert_path": self.ca_cert_path,
|
|
66
|
+
"token_url": self.token_url,
|
|
67
|
+
}
|
|
68
|
+
for secret_field in ("value", "client_id", "client_secret"):
|
|
69
|
+
val = getattr(self, secret_field)
|
|
70
|
+
redacted[secret_field] = "***" if val else None
|
|
71
|
+
if self.custom_headers:
|
|
72
|
+
redacted["custom_headers"] = {k: "***" for k in self.custom_headers}
|
|
73
|
+
return f"AuthCredentials({redacted})"
|
|
74
|
+
|
|
75
|
+
def to_headers(self) -> dict[str, str]:
|
|
76
|
+
"""Generate HTTP headers for this credential.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Dictionary of headers to include in requests
|
|
80
|
+
"""
|
|
81
|
+
headers: dict[str, str] = {}
|
|
82
|
+
if self.custom_headers:
|
|
83
|
+
headers.update(self.custom_headers)
|
|
84
|
+
if self.auth_type == AuthType.BEARER and self.value:
|
|
85
|
+
headers["Authorization"] = f"Bearer {self.value}"
|
|
86
|
+
elif self.auth_type == AuthType.OAUTH2 and self.value:
|
|
87
|
+
headers["Authorization"] = f"Bearer {self.value}"
|
|
88
|
+
elif self.auth_type == AuthType.API_KEY:
|
|
89
|
+
header = self.header_name or "X-API-Key"
|
|
90
|
+
headers[header] = self.value
|
|
91
|
+
return headers
|
|
92
|
+
|
|
93
|
+
def build_ssl_context(self) -> ssl.SSLContext:
|
|
94
|
+
"""Build an SSL context for mTLS client certificate authentication."""
|
|
95
|
+
if self.auth_type != AuthType.MTLS:
|
|
96
|
+
raise ValueError("SSL context can only be built for mTLS credentials")
|
|
97
|
+
if not self.cert_path or not self.key_path:
|
|
98
|
+
raise ValueError("cert_path and key_path are required for mTLS")
|
|
99
|
+
|
|
100
|
+
if self.ca_cert_path:
|
|
101
|
+
ctx = ssl.create_default_context(cafile=self.ca_cert_path)
|
|
102
|
+
else:
|
|
103
|
+
ctx = ssl.create_default_context()
|
|
104
|
+
|
|
105
|
+
ctx.load_cert_chain(certfile=self.cert_path, keyfile=self.key_path)
|
|
106
|
+
return ctx
|
|
107
|
+
|
|
108
|
+
def is_token_expired(self) -> bool:
|
|
109
|
+
"""Check whether the cached OAuth2 access token has expired.
|
|
110
|
+
|
|
111
|
+
Returns True when no token is present, when no expiry was recorded,
|
|
112
|
+
or when the token will expire within a 30-second safety margin.
|
|
113
|
+
"""
|
|
114
|
+
if not self.value:
|
|
115
|
+
return True
|
|
116
|
+
if self._token_expires_at is None:
|
|
117
|
+
return True
|
|
118
|
+
return time.monotonic() >= self._token_expires_at
|
|
119
|
+
|
|
120
|
+
def clear_token(self) -> None:
|
|
121
|
+
"""Clear the cached OAuth2 access token so the next call re-fetches."""
|
|
122
|
+
self.value = ""
|
|
123
|
+
self._token_expires_at = None
|
|
124
|
+
|
|
125
|
+
async def fetch_oauth2_token(self) -> str:
|
|
126
|
+
"""Fetch an access token using OAuth2 client credentials grant.
|
|
127
|
+
|
|
128
|
+
Uses a short-lived HTTP client so that agent auth headers and custom
|
|
129
|
+
headers on the main client are never sent to the token endpoint.
|
|
130
|
+
|
|
131
|
+
Updates self.value with the new token and returns it.
|
|
132
|
+
Parses ``expires_in`` from the token response to track expiry.
|
|
133
|
+
"""
|
|
134
|
+
if self.auth_type != AuthType.OAUTH2:
|
|
135
|
+
raise ValueError("Token fetch is only valid for OAuth2 credentials")
|
|
136
|
+
if not self.token_url or not self.client_id or not self.client_secret:
|
|
137
|
+
raise ValueError("token_url, client_id, and client_secret are required")
|
|
138
|
+
validate_token_url(self.token_url)
|
|
139
|
+
|
|
140
|
+
data: dict[str, str] = {
|
|
141
|
+
"grant_type": "client_credentials",
|
|
142
|
+
"client_id": self.client_id,
|
|
143
|
+
"client_secret": self.client_secret,
|
|
144
|
+
}
|
|
145
|
+
if self.scopes:
|
|
146
|
+
data["scope"] = " ".join(self.scopes)
|
|
147
|
+
|
|
148
|
+
async with httpx.AsyncClient(trust_env=False) as token_client:
|
|
149
|
+
response = await token_client.post(self.token_url, data=data)
|
|
150
|
+
response.raise_for_status()
|
|
151
|
+
token_data = response.json()
|
|
152
|
+
self.value = token_data["access_token"]
|
|
153
|
+
|
|
154
|
+
expires_in = token_data.get("expires_in")
|
|
155
|
+
if isinstance(expires_in, (int, float)) and expires_in > 0:
|
|
156
|
+
self._token_expires_at = (
|
|
157
|
+
time.monotonic() + expires_in - _TOKEN_EXPIRY_MARGIN
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
self._token_expires_at = None
|
|
161
|
+
|
|
162
|
+
return self.value
|
|
163
|
+
|
|
164
|
+
def to_dict(self) -> dict:
|
|
165
|
+
"""Serialize credentials for storage."""
|
|
166
|
+
data: dict = {
|
|
167
|
+
"auth_type": self.auth_type.value,
|
|
168
|
+
"value": self.value,
|
|
169
|
+
"header_name": self.header_name,
|
|
170
|
+
}
|
|
171
|
+
if self.cert_path:
|
|
172
|
+
data["cert_path"] = self.cert_path
|
|
173
|
+
if self.key_path:
|
|
174
|
+
data["key_path"] = self.key_path
|
|
175
|
+
if self.ca_cert_path:
|
|
176
|
+
data["ca_cert_path"] = self.ca_cert_path
|
|
177
|
+
if self.token_url:
|
|
178
|
+
data["token_url"] = self.token_url
|
|
179
|
+
if self.client_id:
|
|
180
|
+
data["client_id"] = self.client_id
|
|
181
|
+
if self.client_secret:
|
|
182
|
+
data["client_secret"] = self.client_secret
|
|
183
|
+
if self.scopes:
|
|
184
|
+
data["scopes"] = self.scopes
|
|
185
|
+
if self.custom_headers:
|
|
186
|
+
data["custom_headers"] = self.custom_headers
|
|
187
|
+
return data
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def from_dict(cls, data: dict) -> "AuthCredentials":
|
|
191
|
+
"""Deserialize credentials from storage."""
|
|
192
|
+
custom_headers_raw = data.get("custom_headers")
|
|
193
|
+
custom_headers = (
|
|
194
|
+
dict(custom_headers_raw) if isinstance(custom_headers_raw, dict) else None
|
|
195
|
+
)
|
|
196
|
+
return cls(
|
|
197
|
+
auth_type=AuthType(data["auth_type"]),
|
|
198
|
+
value=data.get("value") or "",
|
|
199
|
+
header_name=data.get("header_name"),
|
|
200
|
+
cert_path=data.get("cert_path"),
|
|
201
|
+
key_path=data.get("key_path"),
|
|
202
|
+
ca_cert_path=data.get("ca_cert_path"),
|
|
203
|
+
token_url=data.get("token_url"),
|
|
204
|
+
client_id=data.get("client_id"),
|
|
205
|
+
client_secret=data.get("client_secret"),
|
|
206
|
+
scopes=data.get("scopes"),
|
|
207
|
+
custom_headers=custom_headers,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse_header_string(header: str) -> tuple[str, str]:
|
|
212
|
+
"""Parse a 'Name: Value' header string into a (name, value) tuple."""
|
|
213
|
+
if ":" not in header:
|
|
214
|
+
raise ValueError(f"Invalid header format (expected 'Name: Value'): {header}")
|
|
215
|
+
name, _, value = header.partition(":")
|
|
216
|
+
name = name.strip()
|
|
217
|
+
value = value.strip()
|
|
218
|
+
if not name:
|
|
219
|
+
raise ValueError(f"Empty header name in: {header}")
|
|
220
|
+
validate_header_name(name, "custom_header")
|
|
221
|
+
return name, value
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def create_bearer_auth(token: str) -> AuthCredentials:
|
|
225
|
+
"""Create bearer token authentication."""
|
|
226
|
+
return AuthCredentials(auth_type=AuthType.BEARER, value=token)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def create_api_key_auth(
|
|
230
|
+
key: str,
|
|
231
|
+
header_name: str = "X-API-Key",
|
|
232
|
+
) -> AuthCredentials:
|
|
233
|
+
"""Create API key authentication.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
key: The API key value
|
|
237
|
+
header_name: Header name to use (default: X-API-Key)
|
|
238
|
+
"""
|
|
239
|
+
validate_header_name(header_name, "header_name")
|
|
240
|
+
return AuthCredentials(
|
|
241
|
+
auth_type=AuthType.API_KEY,
|
|
242
|
+
value=key,
|
|
243
|
+
header_name=header_name,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def create_mtls_auth(
|
|
248
|
+
cert_path: str,
|
|
249
|
+
key_path: str,
|
|
250
|
+
ca_cert_path: str | None = None,
|
|
251
|
+
) -> AuthCredentials:
|
|
252
|
+
"""Create mTLS (mutual TLS) client certificate authentication."""
|
|
253
|
+
if not Path(cert_path).is_file():
|
|
254
|
+
raise FileNotFoundError(f"Client certificate not found: {cert_path}")
|
|
255
|
+
if not Path(key_path).is_file():
|
|
256
|
+
raise FileNotFoundError(f"Client private key not found: {key_path}")
|
|
257
|
+
if ca_cert_path and not Path(ca_cert_path).is_file():
|
|
258
|
+
raise FileNotFoundError(f"CA certificate not found: {ca_cert_path}")
|
|
259
|
+
check_key_file_permissions(key_path)
|
|
260
|
+
|
|
261
|
+
return AuthCredentials(
|
|
262
|
+
auth_type=AuthType.MTLS,
|
|
263
|
+
cert_path=cert_path,
|
|
264
|
+
key_path=key_path,
|
|
265
|
+
ca_cert_path=ca_cert_path,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def create_oauth2_auth(
|
|
270
|
+
token_url: str,
|
|
271
|
+
client_id: str,
|
|
272
|
+
client_secret: str,
|
|
273
|
+
scopes: list[str] | None = None,
|
|
274
|
+
access_token: str = "",
|
|
275
|
+
) -> AuthCredentials:
|
|
276
|
+
"""Create OAuth2 client credentials authentication."""
|
|
277
|
+
return AuthCredentials(
|
|
278
|
+
auth_type=AuthType.OAUTH2,
|
|
279
|
+
value=access_token,
|
|
280
|
+
token_url=token_url,
|
|
281
|
+
client_id=client_id,
|
|
282
|
+
client_secret=client_secret,
|
|
283
|
+
scopes=scopes,
|
|
284
|
+
)
|