syshttp 0.1.0__tar.gz → 0.1.1__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.
Files changed (105) hide show
  1. {syshttp-0.1.0 → syshttp-0.1.1}/CHANGELOG.md +34 -1
  2. {syshttp-0.1.0 → syshttp-0.1.1}/PKG-INFO +37 -1
  3. {syshttp-0.1.0 → syshttp-0.1.1}/README.md +31 -0
  4. syshttp-0.1.1/docs/api/negotiate.md +3 -0
  5. {syshttp-0.1.0 → syshttp-0.1.1}/docs/index.md +1 -1
  6. {syshttp-0.1.0 → syshttp-0.1.1}/docs/settings.md +54 -0
  7. {syshttp-0.1.0 → syshttp-0.1.1}/mkdocs.yml +1 -0
  8. {syshttp-0.1.0 → syshttp-0.1.1}/pyproject.toml +19 -5
  9. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/AGENTS.md +135 -6
  10. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/__init__.py +12 -1
  11. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_client.py +4 -1
  12. syshttp-0.1.1/src/syshttp/client/_handshake.py +152 -0
  13. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_transport.py +185 -13
  14. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/exceptions.py +24 -1
  15. syshttp-0.1.1/src/syshttp/negotiate.py +327 -0
  16. {syshttp-0.1.0 → syshttp-0.1.1}/tests/client_wire.py +287 -3
  17. syshttp-0.1.1/tests/test_negotiate_auth.py +701 -0
  18. {syshttp-0.1.0 → syshttp-0.1.1}/.gitignore +0 -0
  19. {syshttp-0.1.0 → syshttp-0.1.1}/LICENSE +0 -0
  20. {syshttp-0.1.0 → syshttp-0.1.1}/docs/api/autopatch.md +0 -0
  21. {syshttp-0.1.0 → syshttp-0.1.1}/docs/api/client.md +0 -0
  22. {syshttp-0.1.0 → syshttp-0.1.1}/docs/api/cookies.md +0 -0
  23. {syshttp-0.1.0 → syshttp-0.1.1}/docs/api/env.md +0 -0
  24. {syshttp-0.1.0 → syshttp-0.1.1}/docs/api/exceptions.md +0 -0
  25. {syshttp-0.1.0 → syshttp-0.1.1}/docs/api/hooks.md +0 -0
  26. {syshttp-0.1.0 → syshttp-0.1.1}/docs/api/jar.md +0 -0
  27. {syshttp-0.1.0 → syshttp-0.1.1}/docs/api/proxy.md +0 -0
  28. {syshttp-0.1.0 → syshttp-0.1.1}/docs/api/tls_retry.md +0 -0
  29. {syshttp-0.1.0 → syshttp-0.1.1}/docs/changelog.md +0 -0
  30. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/_fileio.py +0 -0
  31. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/_parse.py +0 -0
  32. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/auth.py +0 -0
  33. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/autopatch.py +0 -0
  34. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/__init__.py +0 -0
  35. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_body.py +0 -0
  36. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_context.py +0 -0
  37. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_deadline.py +0 -0
  38. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_default.py +0 -0
  39. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_fetch.py +0 -0
  40. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_follow.py +0 -0
  41. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_headers.py +0 -0
  42. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_pool.py +0 -0
  43. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_prepare.py +0 -0
  44. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_response.py +0 -0
  45. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_route.py +0 -0
  46. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_settings.py +0 -0
  47. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_state.py +0 -0
  48. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_status.py +0 -0
  49. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/client/_tlstunnel.py +0 -0
  50. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/cookies.py +0 -0
  51. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/env.py +0 -0
  52. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/hooks.py +0 -0
  53. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/jar.py +0 -0
  54. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/proxy.py +0 -0
  55. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/py.typed +0 -0
  56. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/retry.py +0 -0
  57. {syshttp-0.1.0 → syshttp-0.1.1}/src/syshttp/tls.py +0 -0
  58. {syshttp-0.1.0 → syshttp-0.1.1}/tests/client_fakes.py +0 -0
  59. {syshttp-0.1.0 → syshttp-0.1.1}/tests/conftest.py +0 -0
  60. {syshttp-0.1.0 → syshttp-0.1.1}/tests/data/ca.pem +0 -0
  61. {syshttp-0.1.0 → syshttp-0.1.1}/tests/data/client-rsa.key +0 -0
  62. {syshttp-0.1.0 → syshttp-0.1.1}/tests/data/client-rsa.pem +0 -0
  63. {syshttp-0.1.0 → syshttp-0.1.1}/tests/data/server.key +0 -0
  64. {syshttp-0.1.0 → syshttp-0.1.1}/tests/data/server.pem +0 -0
  65. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_autopatch.py +0 -0
  66. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_credential_redirects.py +0 -0
  67. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_deadline.py +0 -0
  68. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_default.py +0 -0
  69. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_default_transport.py +0 -0
  70. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_errors.py +0 -0
  71. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_guards.py +0 -0
  72. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_paths.py +0 -0
  73. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_peer_text.py +0 -0
  74. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_redirect_origin.py +0 -0
  75. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_redirects.py +0 -0
  76. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_reply_bounds.py +0 -0
  77. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_reply_limits.py +0 -0
  78. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_response.py +0 -0
  79. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_routing.py +0 -0
  80. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_secure_cookies.py +0 -0
  81. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_state.py +0 -0
  82. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_tls.py +0 -0
  83. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_urls.py +0 -0
  84. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_client_write_redirects.py +0 -0
  85. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_cookie_hosts.py +0 -0
  86. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_cookie_injection.py +0 -0
  87. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_cookies.py +0 -0
  88. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_edges.py +0 -0
  89. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_env.py +0 -0
  90. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_exceptions.py +0 -0
  91. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_fetch.py +0 -0
  92. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_jar_files.py +0 -0
  93. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_jars_proxy.py +0 -0
  94. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_os_context.py +0 -0
  95. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_proxy.py +0 -0
  96. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_proxy_auth.py +0 -0
  97. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_retry.py +0 -0
  98. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_switches.py +0 -0
  99. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_system_proxy.py +0 -0
  100. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_transport_body.py +0 -0
  101. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_transport_pool.py +0 -0
  102. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_transport_prepare.py +0 -0
  103. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_transport_send.py +0 -0
  104. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_transport_tls.py +0 -0
  105. {syshttp-0.1.0 → syshttp-0.1.1}/tests/test_url_hooks.py +0 -0
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.1] - 2026-11-07
11
+
12
+ ### Added
13
+
14
+ - `syshttp.negotiate`: Negotiate (SPNEGO) and NTLM authentication for the
15
+ default transport, so `HTTPClient`, `fetch()` and `HTTPTransport` answer a
16
+ `401` with `WWW-Authenticate: Negotiate` or `NTLM` and a `407` with
17
+ `Proxy-Authenticate` (a `CONNECT` the proxy challenges included), leg after
18
+ leg on the connection the challenge came on, with `Connection: keep-alive`.
19
+ It is off until `SYSHTTP_NEGOTIATE` (servers) or `SYSHTTP_PROXY_NEGOTIATE`
20
+ (proxies) names a host (`*.suffix` and `*` allowed), or `Negotiate(hosts,
21
+ proxies, *, username, password, plain_http, max_legs, max_token, env)` is
22
+ passed as `negotiate=` to `HTTPClient` or `HTTPTransport`. The identity is the
23
+ Windows logon session (SSPI) or the Kerberos credential cache, or
24
+ `SYSHTTP_NEGOTIATE_USER` and `SYSHTTP_NEGOTIATE_PASSWORD`; a password is in no
25
+ `repr()`, log line or message. A token is not sent over plain http to a host
26
+ that is not loopback unless `SYSHTTP_NEGOTIATE_PLAIN_HTTP` allows it, which
27
+ is logged as a weakening; `SYSHTTP_NEGOTIATE_MAX_LEGS` (default 5) and
28
+ `SYSHTTP_NEGOTIATE_MAX_TOKEN` (default 65536) bound a handshake. A server's
29
+ closing token is checked where the mechanism has mutual authentication. Skipped
30
+ for a request that names its own `Authorization`, a proxy with a credential of
31
+ its own, a body that cannot be sent twice and a gateway.
32
+ - The extras `negotiate` (pyspnego: SSPI on Windows, NTLM in Python) and
33
+ `kerberos` (pyspnego with `gssapi`, for the credential cache on Linux and
34
+ macOS). `import syshttp` and a default install load and need neither.
35
+ - `NegotiationError` (a handshake that failed: credentials refused, a token that
36
+ is not valid or too large, a connection closed or more legs than allowed, a
37
+ handshake that would cross plain http) and `NegotiateUnavailableError` (also an
38
+ `ImportError`: the extra is missing or does not import), both under
39
+ `SysHTTPError` and exported from the root; `has_negotiate()` says whether the
40
+ library imports.
41
+
10
42
  ## [0.1.0] - 2026-10-20
11
43
 
12
44
  ### Added
@@ -339,5 +371,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
339
371
  longer with the userinfo, query and fragment (`user:pass@`,
340
372
  `?access_token=`).
341
373
 
342
- [Unreleased]: https://github.com/jose-pr/syshttp/compare/v0.1.0...HEAD
374
+ [Unreleased]: https://github.com/jose-pr/syshttp/compare/v0.1.1...HEAD
375
+ [0.1.1]: https://github.com/jose-pr/syshttp/compare/v0.1.0...v0.1.1
343
376
  [0.1.0]: https://github.com/jose-pr/syshttp/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: syshttp
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: HTTP plumbing that follows the system: the system proxy (PAC, WPAD) and the OS certificate verifier by default, proxy credentials and prefix gateways, Netscape cookie jars, OS trust-store TLS, URL hooks, and an HTTP client on the standard library
5
5
  Project-URL: Homepage, https://github.com/jose-pr/syshttp
6
6
  Project-URL: Documentation, https://jose-pr.github.io/syshttp/
@@ -29,6 +29,7 @@ Requires-Dist: black; extra == 'dev'
29
29
  Requires-Dist: build; extra == 'dev'
30
30
  Requires-Dist: httpx; extra == 'dev'
31
31
  Requires-Dist: mypy<2; extra == 'dev'
32
+ Requires-Dist: pyspnego<0.13,>=0.12.0; extra == 'dev'
32
33
  Requires-Dist: pytest; extra == 'dev'
33
34
  Requires-Dist: requests; extra == 'dev'
34
35
  Requires-Dist: types-requests; extra == 'dev'
@@ -36,6 +37,10 @@ Provides-Extra: docs
36
37
  Requires-Dist: mkdocs; extra == 'docs'
37
38
  Requires-Dist: mkdocs-material; extra == 'docs'
38
39
  Requires-Dist: mkdocstrings[python]; extra == 'docs'
40
+ Provides-Extra: kerberos
41
+ Requires-Dist: pyspnego[kerberos]<0.13,>=0.12.0; extra == 'kerberos'
42
+ Provides-Extra: negotiate
43
+ Requires-Dist: pyspnego<0.13,>=0.12.0; extra == 'negotiate'
39
44
  Provides-Extra: pac
40
45
  Requires-Dist: proxylib[pac]<3,>=2.0.2; extra == 'pac'
41
46
  Description-Content-Type: text/markdown
@@ -101,6 +106,14 @@ it. Documentation: <https://jose-pr.github.io/syshttp/>.
101
106
  all of the above, on `http.client` and `ssl` alone. Its transport is
102
107
  replaceable: any object with `send()` (a `requests` or `httpx` one, a fake
103
108
  in a test), chosen per client or by `SYSHTTP_DEFAULT_TRANSPORT`.
109
+ - **Negotiate and NTLM, for the servers and proxies you name** — a `401` with
110
+ `WWW-Authenticate: Negotiate` or `NTLM`, and a `407` with `Proxy-Authenticate`
111
+ (a `CONNECT` included), is answered leg after leg on the connection it came
112
+ on: SSPI with the current Windows logon, Kerberos from the credential cache
113
+ where `gssapi` is installed, NTLM with a credential you give. Off until
114
+ `SYSHTTP_NEGOTIATE` or `SYSHTTP_PROXY_NEGOTIATE` names a host, and never over
115
+ plain http to a host that is not loopback unless a setting says so
116
+ (`pip install "syshttp[negotiate]"`).
104
117
  - **Other programs' clients, patched on import** — `SYSHTTP_IMPORTPATCH=1`
105
118
  (or `requests:urllib:httpx:aiohttp:pip`) makes those libraries route and
106
119
  verify the same way in any program of the environment, through
@@ -116,6 +129,8 @@ pip install syshttp
116
129
  | Extra | Adds | Needed for |
117
130
  | --- | --- | --- |
118
131
  | `pac` | a JavaScript engine for proxylib | PAC scripts proxylib evaluates itself (Linux without libproxy) |
132
+ | `negotiate` | pyspnego | Negotiate and NTLM for a server or proxy that challenges: SSPI on Windows, NTLM in Python elsewhere |
133
+ | `kerberos` | pyspnego with `gssapi` | Kerberos from the credential cache on Linux and macOS (builds against the system's Kerberos headers when no wheel exists) |
119
134
 
120
135
  syshttp depends on proxylib (the system proxy), sslcontext (the system's
121
136
  TLS client: the OS verifier through truststore on Python 3.10+, OpenSSL over
@@ -152,6 +167,21 @@ with HTTPClient(cookies=True, tokens=True) as client:
152
167
  response = client.request("GET", "https://api.example.com/thing")
153
168
  ```
154
169
 
170
+ A server or proxy behind Windows-integrated authentication, answered with the
171
+ logon session (nothing is sent unless the host is named; needs
172
+ `pip install "syshttp[negotiate]"`):
173
+
174
+ ```python
175
+ from syshttp.client import HTTPClient
176
+ from syshttp.negotiate import Negotiate
177
+
178
+ with HTTPClient(negotiate=Negotiate("wiki.corp.example", proxies="proxy.corp.example")) as client:
179
+ response = client.request("GET", "https://wiki.corp.example/api/page")
180
+ ```
181
+
182
+ The same from the environment, with no code change:
183
+ `SYSHTTP_NEGOTIATE=wiki.corp.example SYSHTTP_PROXY_NEGOTIATE=proxy.corp.example`.
184
+
155
185
  A separate configuration, isolated from the process environment:
156
186
 
157
187
  ```python
@@ -168,6 +198,7 @@ response = fetch("https://api.example.com/thing", env=env)
168
198
  | `syshttp.env` | `HTTPEnv`: the prefixes and mapping every setting is read from |
169
199
  | `syshttp.proxy` | `proxy_map()`: the settings as one proxylib proxy map |
170
200
  | `syshttp.client` | `HTTPClient` and `fetch()`: the HTTP client, its transport protocol and response |
201
+ | `syshttp.negotiate` | `Negotiate`: which servers and proxies get Negotiate and NTLM, and with whose credentials |
171
202
  | `syshttp.jar` | file and memory cookie/token jars |
172
203
  | `syshttp.cookies` | Netscape cookie files and curl's cookie rules |
173
204
  | `syshttp.hooks` | URL hooks declared in the environment |
@@ -190,6 +221,11 @@ full list is on the docs site:
190
221
  | `SYSHTTP_PROXY_AUTH` | the proxy credential, sent verbatim; else `HTTP_PROXY_AUTH` |
191
222
  | `SYSHTTP_PROXY_AUTH_HEADER` | the header it is sent in (default `Proxy-Authorization`) |
192
223
  | `SYSHTTP_PROXY_TYPE` | `connect` (default) or `prefix[:/endpoint]` |
224
+ | `SYSHTTP_NEGOTIATE` | the servers whose Negotiate or NTLM challenge is answered: host names, `*.suffix`, `*` (default: none) |
225
+ | `SYSHTTP_PROXY_NEGOTIATE` | the same for proxies, by the proxy's host |
226
+ | `SYSHTTP_NEGOTIATE_USER`, `SYSHTTP_NEGOTIATE_PASSWORD` | an explicit identity (a secret) instead of the logon session or credential cache |
227
+ | `SYSHTTP_NEGOTIATE_PLAIN_HTTP` | also authenticate over plain http to a host that is not loopback (off; logged as a weakening) |
228
+ | `SYSHTTP_NEGOTIATE_MAX_LEGS`, `SYSHTTP_NEGOTIATE_MAX_TOKEN` | the limits of one handshake (5 requests, 65536 bytes a token) |
193
229
  | `SYSHTTP_HOME` | the jar directory (default: the per-user state directory) |
194
230
  | `SYSHTTP_IMPORTPATCH` | `1`, or `:`-separated names: the client libraries patched on import |
195
231
  | `SYSHTTP_HOOKS_<KEY>` | a URL hook's pattern, with `_PY` / `_CURL` targets |
@@ -59,6 +59,14 @@ it. Documentation: <https://jose-pr.github.io/syshttp/>.
59
59
  all of the above, on `http.client` and `ssl` alone. Its transport is
60
60
  replaceable: any object with `send()` (a `requests` or `httpx` one, a fake
61
61
  in a test), chosen per client or by `SYSHTTP_DEFAULT_TRANSPORT`.
62
+ - **Negotiate and NTLM, for the servers and proxies you name** — a `401` with
63
+ `WWW-Authenticate: Negotiate` or `NTLM`, and a `407` with `Proxy-Authenticate`
64
+ (a `CONNECT` included), is answered leg after leg on the connection it came
65
+ on: SSPI with the current Windows logon, Kerberos from the credential cache
66
+ where `gssapi` is installed, NTLM with a credential you give. Off until
67
+ `SYSHTTP_NEGOTIATE` or `SYSHTTP_PROXY_NEGOTIATE` names a host, and never over
68
+ plain http to a host that is not loopback unless a setting says so
69
+ (`pip install "syshttp[negotiate]"`).
62
70
  - **Other programs' clients, patched on import** — `SYSHTTP_IMPORTPATCH=1`
63
71
  (or `requests:urllib:httpx:aiohttp:pip`) makes those libraries route and
64
72
  verify the same way in any program of the environment, through
@@ -74,6 +82,8 @@ pip install syshttp
74
82
  | Extra | Adds | Needed for |
75
83
  | --- | --- | --- |
76
84
  | `pac` | a JavaScript engine for proxylib | PAC scripts proxylib evaluates itself (Linux without libproxy) |
85
+ | `negotiate` | pyspnego | Negotiate and NTLM for a server or proxy that challenges: SSPI on Windows, NTLM in Python elsewhere |
86
+ | `kerberos` | pyspnego with `gssapi` | Kerberos from the credential cache on Linux and macOS (builds against the system's Kerberos headers when no wheel exists) |
77
87
 
78
88
  syshttp depends on proxylib (the system proxy), sslcontext (the system's
79
89
  TLS client: the OS verifier through truststore on Python 3.10+, OpenSSL over
@@ -110,6 +120,21 @@ with HTTPClient(cookies=True, tokens=True) as client:
110
120
  response = client.request("GET", "https://api.example.com/thing")
111
121
  ```
112
122
 
123
+ A server or proxy behind Windows-integrated authentication, answered with the
124
+ logon session (nothing is sent unless the host is named; needs
125
+ `pip install "syshttp[negotiate]"`):
126
+
127
+ ```python
128
+ from syshttp.client import HTTPClient
129
+ from syshttp.negotiate import Negotiate
130
+
131
+ with HTTPClient(negotiate=Negotiate("wiki.corp.example", proxies="proxy.corp.example")) as client:
132
+ response = client.request("GET", "https://wiki.corp.example/api/page")
133
+ ```
134
+
135
+ The same from the environment, with no code change:
136
+ `SYSHTTP_NEGOTIATE=wiki.corp.example SYSHTTP_PROXY_NEGOTIATE=proxy.corp.example`.
137
+
113
138
  A separate configuration, isolated from the process environment:
114
139
 
115
140
  ```python
@@ -126,6 +151,7 @@ response = fetch("https://api.example.com/thing", env=env)
126
151
  | `syshttp.env` | `HTTPEnv`: the prefixes and mapping every setting is read from |
127
152
  | `syshttp.proxy` | `proxy_map()`: the settings as one proxylib proxy map |
128
153
  | `syshttp.client` | `HTTPClient` and `fetch()`: the HTTP client, its transport protocol and response |
154
+ | `syshttp.negotiate` | `Negotiate`: which servers and proxies get Negotiate and NTLM, and with whose credentials |
129
155
  | `syshttp.jar` | file and memory cookie/token jars |
130
156
  | `syshttp.cookies` | Netscape cookie files and curl's cookie rules |
131
157
  | `syshttp.hooks` | URL hooks declared in the environment |
@@ -148,6 +174,11 @@ full list is on the docs site:
148
174
  | `SYSHTTP_PROXY_AUTH` | the proxy credential, sent verbatim; else `HTTP_PROXY_AUTH` |
149
175
  | `SYSHTTP_PROXY_AUTH_HEADER` | the header it is sent in (default `Proxy-Authorization`) |
150
176
  | `SYSHTTP_PROXY_TYPE` | `connect` (default) or `prefix[:/endpoint]` |
177
+ | `SYSHTTP_NEGOTIATE` | the servers whose Negotiate or NTLM challenge is answered: host names, `*.suffix`, `*` (default: none) |
178
+ | `SYSHTTP_PROXY_NEGOTIATE` | the same for proxies, by the proxy's host |
179
+ | `SYSHTTP_NEGOTIATE_USER`, `SYSHTTP_NEGOTIATE_PASSWORD` | an explicit identity (a secret) instead of the logon session or credential cache |
180
+ | `SYSHTTP_NEGOTIATE_PLAIN_HTTP` | also authenticate over plain http to a host that is not loopback (off; logged as a weakening) |
181
+ | `SYSHTTP_NEGOTIATE_MAX_LEGS`, `SYSHTTP_NEGOTIATE_MAX_TOKEN` | the limits of one handshake (5 requests, 65536 bytes a token) |
151
182
  | `SYSHTTP_HOME` | the jar directory (default: the per-user state directory) |
152
183
  | `SYSHTTP_IMPORTPATCH` | `1`, or `:`-separated names: the client libraries patched on import |
153
184
  | `SYSHTTP_HOOKS_<KEY>` | a URL hook's pattern, with `_PY` / `_CURL` targets |
@@ -0,0 +1,3 @@
1
+ # `syshttp.negotiate`
2
+
3
+ ::: syshttp.negotiate
@@ -15,7 +15,7 @@ response = fetch("https://example.com/", cookies=True, tokens=True)
15
15
  print(response.status, response.headers["Content-Type"])
16
16
  ```
17
17
 
18
- - [Settings](settings.md): the variables, their prefix, and `HTTPEnv`.
18
+ - [Settings](settings.md): the variables, their prefix, `HTTPEnv`, and Negotiate and NTLM.
19
19
  - API reference: one page per module, in the navigation.
20
20
  - [Changelog](changelog.md).
21
21
 
@@ -17,6 +17,12 @@ read when they are needed, never at import.
17
17
  | `SYSHTTP_PROXY_AUTH` | `SYSHTTP_PROXY`'s credential, a header value sent verbatim to it only (a secret) | the URL's userinfo as Basic |
18
18
  | `SYSHTTP_PROXY_AUTH_HEADER` | the header `SYSHTTP_PROXY_AUTH` rides in | `Proxy-Authorization` |
19
19
  | `SYSHTTP_PROXY_TYPE` | `connect`, or `prefix[:/endpoint]` for a URL-prefix gateway | `connect` |
20
+ | `SYSHTTP_NEGOTIATE` | the servers whose `Negotiate` or `NTLM` challenge is answered: a comma list of host names, `*.suffix` for a domain, `*` for all; needs the `negotiate` extra | none |
21
+ | `SYSHTTP_PROXY_NEGOTIATE` | the same for proxies, by the proxy's host | none |
22
+ | `SYSHTTP_NEGOTIATE_USER`, `SYSHTTP_NEGOTIATE_PASSWORD` | an explicit identity (`DOMAIN\user` or `user@REALM`) for every allowed host; both or neither; the password is a secret | the logon session (SSPI) or the Kerberos credential cache |
23
+ | `SYSHTTP_NEGOTIATE_PLAIN_HTTP` | whether to authenticate over plain http to a host that is not loopback (boolean); each such host is logged as a weakening | off |
24
+ | `SYSHTTP_NEGOTIATE_MAX_LEGS` | most authenticated requests of one handshake | 5 |
25
+ | `SYSHTTP_NEGOTIATE_MAX_TOKEN` | most bytes of one token, sent or received | 65536 |
20
26
  | `SYSHTTP_HOME` | the directory of the cookie and token jars | the per-user state directory |
21
27
  | `SYSHTTP_COOKIE_HOSTS` | groups of hosts that share cookies: hosts split by `,`, groups by `;` | none |
22
28
  | `SYSHTTP_IMPORTPATCH` | client libraries patched as they are imported to use the system proxy and TLS: `1` for all, or `:`-separated names from `requests`, `urllib`, `httpx`, `aiohttp`, `pip` | none |
@@ -96,6 +102,54 @@ does not send an `https:` origin through a plain `prefix+http` gateway, whose
96
102
  hop would carry that origin's tokens and Secure cookies in clear text: use an
97
103
  `https` gateway.
98
104
 
105
+ ## Negotiate and NTLM
106
+
107
+ A server that answers `401` with `WWW-Authenticate: Negotiate` or `NTLM`, and a
108
+ proxy that answers `407` with `Proxy-Authenticate` (to a `CONNECT` as well), is
109
+ answered by `syshttp.client` only for the hosts the settings name, and only
110
+ with the `negotiate` extra installed:
111
+
112
+ ```sh
113
+ pip install "syshttp[negotiate]"
114
+ SYSHTTP_NEGOTIATE="wiki.corp.example,*.apps.corp.example"
115
+ SYSHTTP_PROXY_NEGOTIATE="proxy.corp.example"
116
+ ```
117
+
118
+ A pattern is a host name, `*.suffix` for every host below a domain (not the
119
+ domain itself), or `*`. With neither variable set the library never sends a
120
+ token and a `401` or `407` is the reply, as for any other client.
121
+
122
+ - **Whose identity.** With no `SYSHTTP_NEGOTIATE_USER` and
123
+ `SYSHTTP_NEGOTIATE_PASSWORD`, the logon session on Windows (SSPI) and the
124
+ Kerberos credential cache on Linux and macOS (the `kerberos` extra adds
125
+ `gssapi`; without it pyspnego's own NTLM needs the explicit identity). With
126
+ them, that one identity for every host the lists name. A password is a secret:
127
+ it appears in no `repr()`, log line or message, and belongs in the
128
+ environment or a secret store, never on a command line.
129
+ - **The exchange.** Each leg is the same request again with a token, on the
130
+ connection the challenge came on and with `Connection: keep-alive`, since
131
+ NTLM binds the identity to the connection. The request is not answered when
132
+ it names an `Authorization` (a `Proxy-Authorization` for a proxy), when the
133
+ proxy has a credential of its own, when its body cannot be sent twice, and for
134
+ a prefix gateway. The server's closing token is checked where the mechanism
135
+ has one (Kerberos); NTLM has none.
136
+ - **Plain http.** A token crossing plain http is not tried for a host that is
137
+ not loopback, and the request fails with `NegotiationError` naming
138
+ `SYSHTTP_NEGOTIATE_PLAIN_HTTP`. Setting it to `1` allows it, and each such
139
+ host is logged once as `weakened` under the `syshttp.client` logger. An
140
+ `https:` server is judged by its own scheme; a proxy by the proxy URL's.
141
+ - **Limits and failures.** `SYSHTTP_NEGOTIATE_MAX_LEGS` bounds the
142
+ authenticated requests of one handshake (NTLM takes two) and
143
+ `SYSHTTP_NEGOTIATE_MAX_TOKEN` a token in either direction. Refused
144
+ credentials, a token that is not valid, a closed connection, a limit and a
145
+ failure of the library raise `NegotiationError`, once: nothing is sent again.
146
+ The extra missing raises `NegotiateUnavailableError`, which is also an
147
+ `ImportError`, only for a host the settings name.
148
+
149
+ In code, `HTTPClient(negotiate=Negotiate(...))` (or `HTTPTransport(negotiate=...)`)
150
+ takes the same settings as arguments, which win over the variables; the
151
+ [`syshttp.negotiate` reference](api/negotiate.md) lists them.
152
+
99
153
  ## The default transport
100
154
 
101
155
  An `HTTPClient` (and so `fetch()`) built without a `transport=` sends through
@@ -33,6 +33,7 @@ nav:
33
33
  - env: api/env.md
34
34
  - proxy: api/proxy.md
35
35
  - client: api/client.md
36
+ - negotiate: api/negotiate.md
36
37
  - jar: api/jar.md
37
38
  - cookies: api/cookies.md
38
39
  - hooks: api/hooks.md
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "syshttp"
8
- version = "0.1.0"
8
+ version = "0.1.1"
9
9
  authors = [{ name = "jose-pr", email = "jose-pr@coqui.dev" }]
10
10
  description = "HTTP plumbing that follows the system: the system proxy (PAC, WPAD) and the OS certificate verifier by default, proxy credentials and prefix gateways, Netscape cookie jars, OS trust-store TLS, URL hooks, and an HTTP client on the standard library"
11
11
  readme = "README.md"
@@ -51,9 +51,17 @@ aiohttp = "syshttp.autopatch"
51
51
  # A JavaScript engine for PAC scripts proxylib evaluates itself (Linux without
52
52
  # libproxy); Windows and macOS evaluate PAC in the OS.
53
53
  pac = ["proxylib[pac]>=2.0.2,<3"]
54
+ # Negotiate and NTLM authentication (syshttp.negotiate): SSPI on Windows with the
55
+ # logon session, GSSAPI where `gssapi` is installed (the `kerberos` extra), and
56
+ # NTLM in Python elsewhere.
57
+ negotiate = ["pyspnego>=0.12.0,<0.13"]
58
+ # Kerberos from the credential cache on Linux and macOS: pyspnego's GSSAPI
59
+ # support, which builds against the system's Kerberos headers (krb5-devel,
60
+ # libkrb5-dev) when no wheel exists.
61
+ kerberos = ["pyspnego[kerberos]>=0.12.0,<0.13"]
54
62
  # requests, httpx and aiohttp are what syshttp.autopatch patches: the tests
55
63
  # use them. syshttp's own client needs none of them.
56
- dev = ["requests", "httpx", "aiohttp", "build", "pytest", "black", "mypy<2", "types-requests"]
64
+ dev = ["requests", "httpx", "aiohttp", "build", "pytest", "black", "mypy<2", "types-requests", "syshttp[negotiate]"]
57
65
  docs = ["mkdocs", "mkdocs-material", "mkdocstrings[python]"]
58
66
 
59
67
  [project.urls]
@@ -80,6 +88,12 @@ line-length = 120
80
88
  [tool.pytest.ini_options]
81
89
  pythonpath = ["src"]
82
90
  testpaths = ["tests"]
83
- # Warnings are errors. An exception would be listed here as narrowly as the
84
- # warning's own text, with the reason it is tolerated; there are none.
85
- filterwarnings = ["error"]
91
+ # Warnings are errors. An exception is listed here as narrowly as the warning's
92
+ # own text, with the reason it is tolerated.
93
+ filterwarnings = [
94
+ "error",
95
+ # cryptography, the cipher library of pyspnego, says so at import when its
96
+ # OpenSSL has no legacy provider (a Windows ARM64 Python ships none). NTLM
97
+ # needs RC4, which lives there: the tests that need it probe for it and skip.
98
+ "ignore:OpenSSL 3's legacy provider failed to load:Warning",
99
+ ]
@@ -7,7 +7,7 @@ self-contained. Development documentation lives with the source at
7
7
  <https://github.com/jose-pr/syshttp>.
8
8
 
9
9
  Import names from their modules: `syshttp.env`, `syshttp.proxy`,
10
- `syshttp.client`, `syshttp.jar`, `syshttp.cookies`, `syshttp.hooks`,
10
+ `syshttp.client`, `syshttp.negotiate`, `syshttp.jar`, `syshttp.cookies`, `syshttp.hooks`,
11
11
  `syshttp.tls`, `syshttp.retry`, `syshttp.auth`, `syshttp.exceptions`,
12
12
  `syshttp.autopatch`. Each declares `__all__`: a name outside it is not API,
13
13
  and a module whose name starts with `_` is private. The root re-exports
@@ -19,9 +19,11 @@ module.
19
19
  `pip install syshttp` brings proxylib 2.0.2+ (proxy routing and the system's proxy configuration),
20
20
  sslcontext (the system's TLS client; truststore on Python 3.10+) and
21
21
  importpatch (import-time patching of other HTTP clients, off unless
22
- `SYSHTTP_IMPORTPATCH` asks for it). The extra `pac`
22
+ `SYSHTTP_IMPORTPATCH` asks for it). The extras: `pac`
23
23
  adds a JavaScript engine for PAC scripts proxylib evaluates itself (Linux
24
- without libproxy). The client needs nothing else: `requests` is no dependency
24
+ without libproxy); `negotiate` adds pyspnego for Negotiate and NTLM
25
+ (`syshttp.negotiate`); `kerberos` adds `gssapi` to it for the Kerberos
26
+ credential cache on Linux and macOS. The client needs nothing else: `requests` is no dependency
25
27
  of any feature; only `syshttp.autopatch` touches it, in a program that imports
26
28
  it, when `SYSHTTP_IMPORTPATCH` asks for it.
27
29
 
@@ -175,7 +177,8 @@ class HTTPClient:
175
177
  cookies: bool | str | CookieJar | None = False, tokens: bool | str | TokenJar | None = False,
176
178
  auth_provider: AuthProvider | None = None, retries: int = 3, backoff: float = 1,
177
179
  status_forcelist: Iterable[int] = TRANSIENT_STATUSES,
178
- cookie_hosts: Iterable[Iterable[str]] | None = None)
180
+ cookie_hosts: Iterable[Iterable[str]] | None = None,
181
+ negotiate: Negotiate | None = None)
179
182
  transport # the transport in use
180
183
  def request(self, method, url, *, data=None, headers=None, stream=False, allow_redirects=None,
181
184
  timeout=..., deadline=..., auth=None) -> HTTPResponse
@@ -319,6 +322,11 @@ DEFAULT_TRANSPORT = "SYSHTTP_DEFAULT_TRANSPORT" # setting names
319
322
  is used instead of sending. A request a provider or a hook makes through the
320
323
  client runs neither again; the guard is per thread, so another thread's
321
324
  requests still run them.
325
+ - **Negotiate and NTLM.** `negotiate=` (a `syshttp.negotiate.Negotiate`, see
326
+ its section) configures the default transport as `proxymap` does: a transport
327
+ of your own, or `SYSHTTP_DEFAULT_TRANSPORT`, beside it is refused. Without
328
+ it the transport reads `SYSHTTP_NEGOTIATE` and its kin, which name no host by
329
+ default.
322
330
  - **Retries.** A status in `status_forcelist` (default `TRANSIENT_STATUSES`),
323
331
  or a failed connection (`ConnectionError`, not a timeout, not
324
332
  `NoReachableProxyError`), is sent again up to `retries` times (default 3) for
@@ -433,7 +441,8 @@ class PreparedRequest(NamedTuple):
433
441
  def prepare_request(method, url, *, headers, body=None, timeout) -> PreparedRequest
434
442
 
435
443
  class HTTPTransport:
436
- def __init__(self, *, proxymap=None, env=None, ssl_context=None, proxy_ssl_context=None, verify=True)
444
+ def __init__(self, *, proxymap=None, env=None, ssl_context=None, proxy_ssl_context=None, verify=True,
445
+ negotiate: Negotiate | None = None)
437
446
  def send(self, method, url, *, headers, body=None, timeout, stream=False) -> HTTPResponse
438
447
  def close(self) -> None
439
448
  ```
@@ -504,7 +513,102 @@ process using syshttp.
504
513
  unless it implements them; it can still be used by `fetch(transport=...)`.
505
514
  `HTTPTransport` adds to the contract's exceptions
506
515
  `proxylib.NoReachableProxyError`, `proxylib.UnsupportedProxyError` and
507
- `ConfigurationError`.
516
+ `ConfigurationError`, and `NegotiationError` or `NegotiateUnavailableError`
517
+ for a server or proxy the `negotiate` policy names.
518
+
519
+ ## Negotiate and NTLM (`syshttp.negotiate`)
520
+
521
+ ```python
522
+ class Negotiate:
523
+ def __init__(self, hosts: str | Iterable[str] | None = None, proxies: str | Iterable[str] | None = None, *,
524
+ username: str | None = None, password: str | None = None, plain_http: bool | None = None,
525
+ max_legs: int | None = None, max_token: int | None = None, env: HTTPEnv | None = None) -> None
526
+ def settings(self) -> NegotiateSettings
527
+ def wants(self, host: str, *, proxy: bool = False) -> bool
528
+
529
+ @dataclass(frozen=True)
530
+ class NegotiateSettings: hosts: tuple[str, ...]; proxies: tuple[str, ...]; plain_http: bool; max_legs: int; max_token: int; explicit_credentials: bool
531
+
532
+ def has_negotiate() -> bool
533
+ DEFAULT_MAX_LEGS = 5; DEFAULT_MAX_TOKEN = 65536
534
+ NEGOTIATE = "SYSHTTP_NEGOTIATE"; PROXY_NEGOTIATE = "SYSHTTP_PROXY_NEGOTIATE"
535
+ NEGOTIATE_USER = "SYSHTTP_NEGOTIATE_USER"; NEGOTIATE_PASSWORD = "SYSHTTP_NEGOTIATE_PASSWORD"
536
+ NEGOTIATE_PLAIN_HTTP = "SYSHTTP_NEGOTIATE_PLAIN_HTTP"
537
+ NEGOTIATE_MAX_LEGS = "SYSHTTP_NEGOTIATE_MAX_LEGS"; NEGOTIATE_MAX_TOKEN = "SYSHTTP_NEGOTIATE_MAX_TOKEN" # setting names
538
+ ```
539
+
540
+ `pip install "syshttp[negotiate]"` (pyspnego) is the one dependency; `kerberos`
541
+ adds `gssapi` for the credential cache on Linux and macOS. `import syshttp`,
542
+ `syshttp.negotiate` and `syshttp.client` import neither; pyspnego is imported
543
+ at the first handshake.
544
+
545
+ - **Off by default.** `HTTPTransport(negotiate=...)` and
546
+ `HTTPClient(negotiate=...)` take a `Negotiate`; with none given the transport
547
+ uses `Negotiate(env=env)`. A `401` with `WWW-Authenticate: Negotiate` or `NTLM`
548
+ and a `407` with `Proxy-Authenticate` are answered only for a host the policy
549
+ names; any other is the reply, as for any client. `hosts` (else
550
+ `SYSHTTP_NEGOTIATE`) names servers and `proxies` (else `SYSHTTP_PROXY_NEGOTIATE`)
551
+ proxies, by the host of the proxy: a comma list or an iterable of host names,
552
+ `*.suffix` for every host below a domain (not the domain), `*` for all; an
553
+ empty list is none and beats the variable. A host name is compared without
554
+ case, brackets or a trailing dot.
555
+ - **The exchange.** The transport sends the same request again with `Authorization:
556
+ <Negotiate|NTLM> <base64 token>` (`Proxy-Authorization` for a proxy) on the
557
+ connection the challenge came on, with `Connection: keep-alive`, until the reply
558
+ is not the challenge again; `Negotiate` is preferred when a reply offers
559
+ both. A `CONNECT` the proxy answers `407` is answered the same way, on the
560
+ proxy connection, before the tunnel is made; a proxy's challenge is
561
+ answered before a server's, each once. The body of a challenge is read (up to
562
+ 64 KiB) and dropped; the reply that follows is what `send()` returns, and
563
+ the connection, now authenticated, goes back to the pool. The server's
564
+ closing token is taken from that reply when the mechanism proves the server
565
+ (Kerberos): a reply of 400 or more is returned unchecked; an incomplete
566
+ exchange with no closing token, or one the token does not complete, is
567
+ `NegotiationError`; NTLM has none.
568
+ - **Not answered** (the reply is returned): the request names an `Authorization`
569
+ (a `Proxy-Authorization` for a proxy challenge), the proxy has a credential of
570
+ its own (userinfo, headers), the body cannot be sent twice (a file, an
571
+ iterable), the route is a prefix gateway, or the reply offers neither scheme. A
572
+ plain-http server behind a proxy that also challenges is answered after the
573
+ proxy, but the proxy's token is not repeated on the server's legs: a
574
+ Kerberos proxy asks again and its `407` is returned. A transport of your own
575
+ does its own authentication.
576
+ - **Identity.** `username` and `password` (else `SYSHTTP_NEGOTIATE_USER` and
577
+ `SYSHTTP_NEGOTIATE_PASSWORD`, both or `ConfigurationError`) are one identity
578
+ for every host the policy names, as `DOMAIN\user` or `user@REALM`; with none,
579
+ the Windows logon session (SSPI) or the Kerberos credential cache. The
580
+ password is a secret held by the object and in no `repr()`, message, log
581
+ line or exception attribute; `repr(Negotiate)` says `credentials=explicit`
582
+ or `logon`, `NegotiateSettings` holds `explicit_credentials`, never the
583
+ identity. A caller's bad argument is `ValueError` or `TypeError` (a half
584
+ identity, a line break in either part, `max_legs` below 1, a host that is not a
585
+ name or pattern); a variable that cannot be used is `ConfigurationError`
586
+ naming it, refused when the transport is made.
587
+ - **Plain http.** A handshake over `http:` (the server's scheme; for a proxy
588
+ challenge the proxy URL's) to a host that is not `localhost`, `127.0.0.0/8` or
589
+ `::1` is `NegotiationError` naming `SYSHTTP_NEGOTIATE_PLAIN_HTTP`, before any
590
+ token is made. `plain_http=True` (else the variable, a boolean) allows it;
591
+ each such server or proxy is then logged once per transport at WARNING, under
592
+ `syshttp.client`, as `weakened`. An `https:` server through a proxy is judged
593
+ by its own scheme.
594
+ - **Limits.** `max_legs` (default 5, `SYSHTTP_NEGOTIATE_MAX_LEGS`) is the most
595
+ authenticated requests of one handshake: NTLM takes two, Kerberos one or two.
596
+ `max_token` (default 65536, `SYSHTTP_NEGOTIATE_MAX_TOKEN`) is the most
597
+ bytes of a token, sent or received. Past either: `NegotiationError`, and
598
+ nothing more is sent.
599
+ - **Failures.** `NegotiationError` (message: the host and port, the
600
+ mechanism, never a token or a credential): the credentials refused (the
601
+ challenge again), a challenge answered with a token that is not valid
602
+ base64, a server that closes the connection or sends more than 64 KiB between
603
+ legs, a limit, the library's own failure (`__cause__`), a refusal over plain
604
+ http. `NegotiateUnavailableError` (also `ImportError`): pyspnego is
605
+ missing or does not import, naming `pip install "syshttp[negotiate]"`; raised
606
+ only for a host the policy names. A handshake is never sent again by
607
+ syshttp: `NegotiationError` is not a `ConnectionError`, so the client's retries
608
+ do not repeat it.
609
+ - **Channel binding** (Extended Protection for Authentication) is not sent: a
610
+ server that requires it refuses the exchange. `has_negotiate()` is whether
611
+ pyspnego imports.
508
612
 
509
613
  ## Exceptions (`syshttp.exceptions`)
510
614
 
@@ -513,6 +617,8 @@ class SysHTTPError(Exception)
513
617
  class ConfigurationError(SysHTTPError, ValueError)
514
618
  class HookError(SysHTTPError, RuntimeError)
515
619
  class ResponseError(SysHTTPError, ValueError)
620
+ class NegotiationError(SysHTTPError)
621
+ class NegotiateUnavailableError(NegotiationError, ImportError)
516
622
  ```
517
623
 
518
624
  - `SysHTTPError` is the base of what goes wrong at run time. proxylib's own
@@ -524,6 +630,10 @@ class ResponseError(SysHTTPError, ValueError)
524
630
  `SYSHTTP_PREFIX`). The message names the variable or key, never a secret.
525
631
  - `HookError`: a `_PY` hook failed; the hook's own exception is `__cause__`.
526
632
  - `ResponseError`: a body that is not the JSON `HTTPResponse.json()` was asked for.
633
+ - `NegotiationError`: a Negotiate or NTLM handshake that did not authenticate
634
+ (see "Negotiate and NTLM"); `NegotiateUnavailableError` is the one raised when
635
+ pyspnego is missing or does not import, naming the `negotiate` extra. The
636
+ message names the host, never a token or a credential.
527
637
  - A caller's own mistake is a plain `ValueError` or `TypeError`, not one of
528
638
  these: a malformed `prefixes=` argument, `verify=False` beside `ssl_context`,
529
639
  a `client` beside `client_options` in `fetch()`, an unknown patch target.
@@ -776,6 +886,13 @@ def patched() -> tuple[str, ...]
776
886
  | `HTTP_PROXY_AUTH` | **secret**: the machine's proxy's credential (a `*_PROXY` variable's or the system's), as `Proxy-Authorization` | the URL's userinfo as Basic |
777
887
  | `SYSHTTP_PROXY_AUTH_HEADER` | the header name `SYSHTTP_PROXY_AUTH` rides in | `Proxy-Authorization` |
778
888
  | `SYSHTTP_PROXY_TYPE` | `connect` or `prefix[:/endpoint]` | `connect` |
889
+ | `SYSHTTP_NEGOTIATE` | the servers whose Negotiate or NTLM challenge is answered: comma list of host names, `*.suffix`, `*` | none |
890
+ | `SYSHTTP_PROXY_NEGOTIATE` | the same for proxies, by the proxy's host | none |
891
+ | `SYSHTTP_NEGOTIATE_USER` | the explicit identity's name, `DOMAIN\user` or `user@REALM` (with `_PASSWORD`) | the logon session or credential cache |
892
+ | `SYSHTTP_NEGOTIATE_PASSWORD` | **secret**: the explicit identity's password | none |
893
+ | `SYSHTTP_NEGOTIATE_PLAIN_HTTP` | boolean: authenticate over plain http to a host that is not loopback (logged as a weakening) | off |
894
+ | `SYSHTTP_NEGOTIATE_MAX_LEGS` | most authenticated requests of one handshake | 5 |
895
+ | `SYSHTTP_NEGOTIATE_MAX_TOKEN` | most bytes of one token, sent or received | 65536 |
779
896
  | `SYSHTTP_HOME` | the jar root | the per-user state directory |
780
897
  | `SYSHTTP_COOKIE_HOSTS` | groups of hosts that share cookies: hosts split by `,`, groups by `;` (`HTTPClient`) | none |
781
898
  | `SYSHTTP_DEFAULT_TRANSPORT` | the transport of an `HTTPClient` (and `fetch()`) given none: `module:attr` or `<file>.py:attr`, an object with `send()` or something that makes one. It names code to import | syshttp's `HTTPTransport` |
@@ -815,3 +932,15 @@ proxylib's own `PROXYLIB_IMPORTPATCH` and `PROXYLIB_AUTOPROXY`.
815
932
  an untrusted party set the environment of a process using syshttp.
816
933
  - A client keeps the cookies its responses set for its whole life, as a
817
934
  session does: build one per identity, or pass `cookies=` a jar of its own.
935
+ - Negotiate and NTLM send nothing until `SYSHTTP_NEGOTIATE` or
936
+ `SYSHTTP_PROXY_NEGOTIATE` (or a `Negotiate(hosts=...)`) names the host: the
937
+ logon identity is released only to hosts you list, never to whatever
938
+ challenges. Over plain http, to anything but loopback, it needs
939
+ `SYSHTTP_NEGOTIATE_PLAIN_HTTP` as well.
940
+ - In a venv of the Windows ARM64 CPython 3.9 and 3.14 tested (cryptography
941
+ 50.0.2, pyspnego 0.12.4), `import spnego` fails with "DLL load failed" until
942
+ the interpreter's `DLLs` directory is on the DLL search path
943
+ (`os.add_dll_directory`), and then warns that OpenSSL's legacy provider did
944
+ not load, which leaves pyspnego's own NTLM without RC4; SSPI, once the import
945
+ works, does not need it. `has_negotiate()` is the probe, and a handshake that
946
+ needs what is missing is `NegotiationError` or `NegotiateUnavailableError`.
@@ -10,6 +10,8 @@ Public surface (the package's AGENTS.md documents each name):
10
10
  --noproxy overrides included)
11
11
  proxy.configured_proxy / proxy_type — SYSHTTP_PROXY as a proxylib Proxy; SYSHTTP_PROXY_TYPE
12
12
  (connect | prefix[:/endpoint])
13
+ negotiate.Negotiate — Negotiate and NTLM for the servers and proxies SYSHTTP_NEGOTIATE /
14
+ SYSHTTP_PROXY_NEGOTIATE name (the `negotiate` extra)
13
15
  client.HTTPClient / fetch — the client: proxy map, system TLS, cookies, tokens,
14
16
  hooks, retries; replaceable transport. ``fetch`` is
15
17
  also ``syshttp.fetch``
@@ -32,12 +34,21 @@ from importlib.metadata import PackageNotFoundError, version
32
34
  from typing import Any
33
35
 
34
36
  from .env import HTTPEnv
35
- from .exceptions import ConfigurationError, HookError, ResponseError, SysHTTPError
37
+ from .exceptions import (
38
+ ConfigurationError,
39
+ HookError,
40
+ NegotiateUnavailableError,
41
+ NegotiationError,
42
+ ResponseError,
43
+ SysHTTPError,
44
+ )
36
45
 
37
46
  __all__ = [
38
47
  "ConfigurationError",
39
48
  "HTTPEnv",
40
49
  "HookError",
50
+ "NegotiateUnavailableError",
51
+ "NegotiationError",
41
52
  "NoReachableProxyError",
42
53
  "ResponseError",
43
54
  "SysHTTPError",
@@ -14,6 +14,7 @@ from .. import __version__
14
14
  from .. import hooks as _hooks
15
15
  from ..env import HTTPEnv
16
16
  from ..jar import CookieJar, TokenJar
17
+ from ..negotiate import Negotiate
17
18
  from ..retry import TRANSIENT_STATUSES, retry_after
18
19
  from . import _follow, _settings
19
20
  from ._deadline import Deadline, check_deadline
@@ -123,6 +124,7 @@ class HTTPClient:
123
124
  backoff: float = 1,
124
125
  status_forcelist: "_ty.Iterable[int]" = TRANSIENT_STATUSES,
125
126
  cookie_hosts: "_ty.Iterable[_ty.Iterable[str]] | None" = None,
127
+ negotiate: "Negotiate | None" = None,
126
128
  ):
127
129
  if transport is not None and not callable(getattr(transport, "send", None)):
128
130
  raise TypeError("a transport is an object with a send() method")
@@ -136,6 +138,7 @@ class HTTPClient:
136
138
  "ssl_context": ssl_context,
137
139
  "proxy_ssl_context": proxy_ssl_context,
138
140
  "verify": verify,
141
+ "negotiate": negotiate,
139
142
  }
140
143
  self._transport = transport
141
144
  self._built = None
@@ -143,7 +146,7 @@ class HTTPClient:
143
146
  if transport is not None:
144
147
  if any(value is not (True if name == "verify" else None) for name, value in options.items()):
145
148
  raise ValueError(
146
- "proxymap, ssl_context, proxy_ssl_context and verify configure the default transport: "
149
+ "proxymap, ssl_context, proxy_ssl_context, verify and negotiate configure the default transport: "
147
150
  "a client given a transport leaves them to it"
148
151
  )
149
152
  else: