haru-cli 0.1.3__tar.gz → 0.1.4__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.
- {haru_cli-0.1.3 → haru_cli-0.1.4}/CHANGELOG.md +16 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/PKG-INFO +1 -1
- {haru_cli-0.1.3 → haru_cli-0.1.4}/pyproject.toml +1 -1
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/sso.py +126 -6
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_sso.py +120 -1
- {haru_cli-0.1.3 → haru_cli-0.1.4}/uv.lock +1 -1
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.github/workflows/ci.yml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.github/workflows/release.yml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.gitignore +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/coding-standards.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/git-workflow.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/product.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/security.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/structure.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/tech.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.kiro/steering/testing.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.pre-commit-config.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/.python-version +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/CLAUDE.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/LICENSE +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/README.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/config/agents.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/config/guardrails.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/config/haru.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/config/logging.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/config/mcp.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/config/models.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/config/prompts/researcher.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/config/prompts/supervisor.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/config/prompts/writer.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/docs/configuration.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/renovate.json +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/__init__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/__main__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/agents/__init__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/agents/factory.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/agents/orchestration.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/__init__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/cache.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/identity.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/pkce.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/auth/session.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/cli.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/__init__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/agents.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/chat.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/config.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/login.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/run.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/session.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/commands/streaming.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/__init__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/loader.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/schema.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/__init__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/agents.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/haru.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/logging.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/mcp.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/models.yaml +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/prompts/researcher.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/prompts/supervisor.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/config/templates/prompts/writer.md +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/errors.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/models/__init__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/models/bedrock.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/observability/__init__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/observability/guardrails.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/observability/telemetry.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/sessions/__init__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/sessions/manager.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/steering/__init__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/steering/prompts.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/tools/__init__.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/tools/mcp.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/src/haru/tools/registry.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/conftest.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/integration/test_packaging.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_agent_factory.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_bedrock.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_cache.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_cli.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_config_command.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_config_loader.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_guardrails.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_identity.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_main.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_mcp.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_orchestration.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_pkce.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_prompts.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_session.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_sessions.py +0 -0
- {haru_cli-0.1.3 → haru_cli-0.1.4}/tests/unit/test_telemetry.py +0 -0
|
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
+
- The login callback now serves a proper local result page (dark themed,
|
|
13
|
+
approval/denial card): "Request approved" on success, "Request denied"
|
|
14
|
+
with the reason on error, missing parameters, or a state mismatch —
|
|
15
|
+
rendered server-side with no JavaScript.
|
|
16
|
+
|
|
17
|
+
### Security
|
|
18
|
+
|
|
19
|
+
- Login result pages ship strict browser security headers: CSP
|
|
20
|
+
(`default-src 'none'`, inline styles only, `frame-ancestors 'none'`),
|
|
21
|
+
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
|
|
22
|
+
`Referrer-Policy: no-referrer`, and `Cache-Control: no-store`; error
|
|
23
|
+
descriptions are HTML-escaped, and the `state` parameter is now also
|
|
24
|
+
validated in the callback handler before the browser is answered.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
12
28
|
- Browser-only login: `haru login` now discovers your accessible AWS
|
|
13
29
|
accounts and permission-set roles from Identity Center after sign-in
|
|
14
30
|
(auto-selected when unambiguous, prompted otherwise) and remembers the
|
|
@@ -6,6 +6,7 @@ endpoint, capture the code on a 127.0.0.1 loopback listener, and exchange it
|
|
|
6
6
|
with ``CreateToken``. Only loopback redirect URIs are accepted.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import html
|
|
9
10
|
import secrets
|
|
10
11
|
import time
|
|
11
12
|
import webbrowser
|
|
@@ -13,6 +14,7 @@ from collections.abc import Callable
|
|
|
13
14
|
from dataclasses import dataclass
|
|
14
15
|
from datetime import UTC, datetime, timedelta
|
|
15
16
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
17
|
+
from string import Template
|
|
16
18
|
from typing import Any, cast
|
|
17
19
|
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
18
20
|
|
|
@@ -26,9 +28,74 @@ from haru.errors import AuthError
|
|
|
26
28
|
_LOOPBACK_HOST = "127.0.0.1"
|
|
27
29
|
_CALLBACK_PATH = "/oauth/callback"
|
|
28
30
|
_AUTHORIZE_SCOPES = "sso:account:access"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
|
|
32
|
+
# Self-contained result page: inline CSS and inline SVG only — no scripts and
|
|
33
|
+
# no external resources. The outcome is rendered server-side (unlike client
|
|
34
|
+
# scripting, this works under the strict CSP below) and the strict CSP
|
|
35
|
+
# enforces the no-script, no-resource policy in the browser.
|
|
36
|
+
_PAGE_TEMPLATE = Template("""<!DOCTYPE html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="utf-8">
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
41
|
+
<title>$title</title>
|
|
42
|
+
<style>
|
|
43
|
+
html { height: 100%; }
|
|
44
|
+
body {
|
|
45
|
+
box-sizing: border-box; min-height: 100%; margin: 0; padding: 15px 30px;
|
|
46
|
+
display: flex; flex-direction: column; justify-content: center;
|
|
47
|
+
align-items: center; min-width: 400px;
|
|
48
|
+
font-family: Verdana, Geneva, Tahoma, sans-serif; font-size: 0.9rem;
|
|
49
|
+
background-color: #000000; color: #e0e0e0;
|
|
50
|
+
}
|
|
51
|
+
.logo { font-size: 2rem; font-weight: bold; letter-spacing: 0.04em;
|
|
52
|
+
margin-bottom: 50px; color: #ffffff; }
|
|
53
|
+
.logo span { color: #7b5cfa; }
|
|
54
|
+
.request {
|
|
55
|
+
border: 1px solid; border-radius: 6px; padding: 20px 24px;
|
|
56
|
+
display: flex; flex-direction: row; align-items: flex-start; gap: 10px;
|
|
57
|
+
}
|
|
58
|
+
.request h4 { margin: 0 0 4px 0; }
|
|
59
|
+
.request p { margin: 0; opacity: 0.85; }
|
|
60
|
+
.approval { background-color: rgba(46, 204, 113, 0.08); border-color: #2ecc71; }
|
|
61
|
+
.approval h4, .approval p { color: #2ecc71; }
|
|
62
|
+
.denial { background-color: rgba(231, 76, 60, 0.08); border-color: #e74c3c; }
|
|
63
|
+
.denial h4, .denial p { color: #e74c3c; }
|
|
64
|
+
.icon { flex-shrink: 0; margin-top: 2px; margin-right: 5px; }
|
|
65
|
+
.hint { color: #a0a0b8; text-align: center; margin-top: 24px; }
|
|
66
|
+
</style>
|
|
67
|
+
</head>
|
|
68
|
+
<body>
|
|
69
|
+
<div class="logo">haru <span>CLI</span></div>
|
|
70
|
+
<div>
|
|
71
|
+
<div class="request $variant">
|
|
72
|
+
<svg class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
|
73
|
+
stroke="$stroke" stroke-width="2" stroke-linecap="round"
|
|
74
|
+
stroke-linejoin="round">$icon</svg>
|
|
75
|
+
<div>
|
|
76
|
+
<h4>$heading</h4>
|
|
77
|
+
<p>$message</p>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<p class="hint">$footer</p>
|
|
81
|
+
</div>
|
|
82
|
+
</body>
|
|
83
|
+
</html>""")
|
|
84
|
+
|
|
85
|
+
_CHECK_ICON = '<polyline points="20 6 9 17 4 12"></polyline>'
|
|
86
|
+
_CROSS_ICON = (
|
|
87
|
+
'<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
_SECURITY_HEADERS = (
|
|
91
|
+
(
|
|
92
|
+
"Content-Security-Policy",
|
|
93
|
+
"default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'",
|
|
94
|
+
),
|
|
95
|
+
("X-Content-Type-Options", "nosniff"),
|
|
96
|
+
("X-Frame-Options", "DENY"),
|
|
97
|
+
("Referrer-Policy", "no-referrer"),
|
|
98
|
+
("Cache-Control", "no-store"),
|
|
32
99
|
)
|
|
33
100
|
|
|
34
101
|
|
|
@@ -142,6 +209,7 @@ def run_login(
|
|
|
142
209
|
registration = register_client(oidc, sso.client_name, sso.start_url, redirect_uri)
|
|
143
210
|
verifier, challenge = generate_pkce_pair()
|
|
144
211
|
state = secrets.token_urlsafe(32)
|
|
212
|
+
server.expected_state = state
|
|
145
213
|
open_url(
|
|
146
214
|
build_authorize_url(
|
|
147
215
|
sso.sso_region, registration.client_id, redirect_uri, state, challenge
|
|
@@ -159,6 +227,7 @@ class _CallbackServer(HTTPServer):
|
|
|
159
227
|
"""Loopback HTTP server that records the OAuth callback query parameters."""
|
|
160
228
|
|
|
161
229
|
callback_params: dict[str, list[str]] | None = None
|
|
230
|
+
expected_state: str | None = None
|
|
162
231
|
|
|
163
232
|
|
|
164
233
|
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
@@ -166,20 +235,71 @@ class _CallbackHandler(BaseHTTPRequestHandler):
|
|
|
166
235
|
|
|
167
236
|
def do_GET(self) -> None:
|
|
168
237
|
parsed = urlsplit(self.path)
|
|
238
|
+
if parsed.path == "/favicon.ico":
|
|
239
|
+
self.send_response(204)
|
|
240
|
+
self.end_headers()
|
|
241
|
+
return
|
|
169
242
|
if parsed.path != _CALLBACK_PATH:
|
|
170
243
|
self.send_error(404)
|
|
171
244
|
return
|
|
172
245
|
server = cast(_CallbackServer, self.server)
|
|
173
|
-
|
|
246
|
+
params = parse_qs(parsed.query)
|
|
247
|
+
server.callback_params = params
|
|
248
|
+
self._respond(_render_outcome(params, server.expected_state))
|
|
249
|
+
|
|
250
|
+
def _respond(self, page: bytes) -> None:
|
|
174
251
|
self.send_response(200)
|
|
175
|
-
self.send_header("Content-Type", "text/html")
|
|
252
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
253
|
+
self.send_header("Content-Length", str(len(page)))
|
|
254
|
+
for header, value in _SECURITY_HEADERS:
|
|
255
|
+
self.send_header(header, value)
|
|
176
256
|
self.end_headers()
|
|
177
|
-
self.wfile.write(
|
|
257
|
+
self.wfile.write(page)
|
|
178
258
|
|
|
179
259
|
def log_message(self, format: str, *args: object) -> None:
|
|
180
260
|
"""Silence default request logging; callback URLs must never be logged."""
|
|
181
261
|
|
|
182
262
|
|
|
263
|
+
def _render_outcome(params: dict[str, list[str]], expected_state: str | None) -> bytes:
|
|
264
|
+
"""Render the approval or denial page for the callback the browser sent."""
|
|
265
|
+
error = params.get("error", [""])[0]
|
|
266
|
+
if error:
|
|
267
|
+
description = params.get("error_description", [""])[0]
|
|
268
|
+
reason = f"{error}: {description}" if description else error
|
|
269
|
+
return _failure_page(html.escape(reason))
|
|
270
|
+
codes = params.get("code")
|
|
271
|
+
states = params.get("state")
|
|
272
|
+
if not codes or not states:
|
|
273
|
+
return _failure_page("The sign-in response was missing required parameters")
|
|
274
|
+
if expected_state is not None and not secrets.compare_digest(states[0], expected_state):
|
|
275
|
+
return _failure_page("The sign-in response failed a security check (state mismatch)")
|
|
276
|
+
return _success_page()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _success_page() -> bytes:
|
|
280
|
+
return _PAGE_TEMPLATE.substitute(
|
|
281
|
+
title="haru CLI authentication",
|
|
282
|
+
variant="approval",
|
|
283
|
+
stroke="#2ecc71",
|
|
284
|
+
icon=_CHECK_ICON,
|
|
285
|
+
heading="Request approved",
|
|
286
|
+
message="haru CLI has been given the requested permissions",
|
|
287
|
+
footer="You can close this window and start using haru CLI",
|
|
288
|
+
).encode("utf-8")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _failure_page(reason: str) -> bytes:
|
|
292
|
+
return _PAGE_TEMPLATE.substitute(
|
|
293
|
+
title="haru CLI authentication",
|
|
294
|
+
variant="denial",
|
|
295
|
+
stroke="#e74c3c",
|
|
296
|
+
icon=_CROSS_ICON,
|
|
297
|
+
heading="Request denied",
|
|
298
|
+
message=reason,
|
|
299
|
+
footer="You can close this window and re-run haru login",
|
|
300
|
+
).encode("utf-8")
|
|
301
|
+
|
|
302
|
+
|
|
183
303
|
def _start_loopback_server(port: int) -> _CallbackServer:
|
|
184
304
|
"""Bind the loopback callback server (port 0 selects an ephemeral port)."""
|
|
185
305
|
try:
|
|
@@ -151,6 +151,21 @@ def _get(url: str) -> None:
|
|
|
151
151
|
pass
|
|
152
152
|
|
|
153
153
|
|
|
154
|
+
class _Capture:
|
|
155
|
+
"""Fetch a URL from a thread, capturing body, headers, and status."""
|
|
156
|
+
|
|
157
|
+
def __init__(self) -> None:
|
|
158
|
+
self.body = ""
|
|
159
|
+
self.headers: dict[str, str] = {}
|
|
160
|
+
self.status = 0
|
|
161
|
+
|
|
162
|
+
def fetch(self, url: str) -> None:
|
|
163
|
+
with urllib.request.urlopen(url) as response: # noqa: S310 - loopback URL
|
|
164
|
+
self.status = response.status
|
|
165
|
+
self.headers = {key.lower(): value for key, value in response.headers.items()}
|
|
166
|
+
self.body = response.read().decode("utf-8")
|
|
167
|
+
|
|
168
|
+
|
|
154
169
|
def test_loopback_callback_roundtrip() -> None:
|
|
155
170
|
"""The loopback server captures code and state from a real local request."""
|
|
156
171
|
server = _start_loopback_server(0)
|
|
@@ -194,7 +209,7 @@ def test_callback_ignores_other_paths() -> None:
|
|
|
194
209
|
|
|
195
210
|
def _hit_wrong_then_right() -> None:
|
|
196
211
|
with contextlib.suppress(urllib.error.HTTPError):
|
|
197
|
-
_get(f"http://127.0.0.1:{port}/
|
|
212
|
+
_get(f"http://127.0.0.1:{port}/wrong-path")
|
|
198
213
|
_get(f"http://127.0.0.1:{port}/oauth/callback?code=abc&state=xyz")
|
|
199
214
|
|
|
200
215
|
thread = threading.Thread(target=_hit_wrong_then_right)
|
|
@@ -206,6 +221,110 @@ def test_callback_ignores_other_paths() -> None:
|
|
|
206
221
|
assert (code, state) == ("abc", "xyz")
|
|
207
222
|
|
|
208
223
|
|
|
224
|
+
def _serve_one(url: str, server: Any) -> _Capture:
|
|
225
|
+
"""Serve exactly one request to ``url``, returning the captured response."""
|
|
226
|
+
capture = _Capture()
|
|
227
|
+
thread = threading.Thread(target=capture.fetch, args=(url,))
|
|
228
|
+
thread.start()
|
|
229
|
+
server.timeout = 10.0
|
|
230
|
+
server.handle_request()
|
|
231
|
+
thread.join(timeout=10.0)
|
|
232
|
+
return capture
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_success_page_content_and_security_headers() -> None:
|
|
236
|
+
"""A valid callback renders the success page with strict browser headers."""
|
|
237
|
+
server = _start_loopback_server(0)
|
|
238
|
+
try:
|
|
239
|
+
server.expected_state = "xyz"
|
|
240
|
+
port = server.server_address[1]
|
|
241
|
+
capture = _serve_one(f"http://127.0.0.1:{port}/oauth/callback?code=abc&state=xyz", server)
|
|
242
|
+
finally:
|
|
243
|
+
server.server_close()
|
|
244
|
+
|
|
245
|
+
assert capture.status == 200
|
|
246
|
+
assert "Request approved" in capture.body
|
|
247
|
+
assert "start using haru CLI" in capture.body
|
|
248
|
+
assert capture.headers["content-type"] == "text/html; charset=utf-8"
|
|
249
|
+
assert capture.headers["content-length"] == str(len(capture.body.encode("utf-8")))
|
|
250
|
+
csp = capture.headers["content-security-policy"]
|
|
251
|
+
assert "default-src 'none'" in csp
|
|
252
|
+
assert "frame-ancestors 'none'" in csp
|
|
253
|
+
assert capture.headers["x-content-type-options"] == "nosniff"
|
|
254
|
+
assert capture.headers["x-frame-options"] == "DENY"
|
|
255
|
+
assert capture.headers["referrer-policy"] == "no-referrer"
|
|
256
|
+
assert capture.headers["cache-control"] == "no-store"
|
|
257
|
+
assert "<script" not in capture.body
|
|
258
|
+
assert "src=" not in capture.body
|
|
259
|
+
assert "href=" not in capture.body
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_denied_callback_renders_failure_page() -> None:
|
|
263
|
+
"""An error callback shows the failure page, and the CLI still errors."""
|
|
264
|
+
server = _start_loopback_server(0)
|
|
265
|
+
try:
|
|
266
|
+
port = server.server_address[1]
|
|
267
|
+
url = (
|
|
268
|
+
f"http://127.0.0.1:{port}/oauth/callback"
|
|
269
|
+
"?error=access_denied&error_description=User+denied"
|
|
270
|
+
)
|
|
271
|
+
capture = _serve_one(url, server)
|
|
272
|
+
with pytest.raises(AuthError, match="access_denied"):
|
|
273
|
+
_await_callback(server, timeout_seconds=1.0)
|
|
274
|
+
finally:
|
|
275
|
+
server.server_close()
|
|
276
|
+
|
|
277
|
+
assert "Request denied" in capture.body
|
|
278
|
+
assert "access_denied" in capture.body
|
|
279
|
+
assert "Request approved" not in capture.body
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_forged_state_renders_failure_page() -> None:
|
|
283
|
+
"""A state mismatch shows the failure page in the browser."""
|
|
284
|
+
server = _start_loopback_server(0)
|
|
285
|
+
try:
|
|
286
|
+
server.expected_state = "expected"
|
|
287
|
+
port = server.server_address[1]
|
|
288
|
+
capture = _serve_one(
|
|
289
|
+
f"http://127.0.0.1:{port}/oauth/callback?code=abc&state=forged", server
|
|
290
|
+
)
|
|
291
|
+
finally:
|
|
292
|
+
server.server_close()
|
|
293
|
+
|
|
294
|
+
assert "Request denied" in capture.body
|
|
295
|
+
assert "security check" in capture.body
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_error_description_is_escaped() -> None:
|
|
299
|
+
"""HTML in the error description cannot inject markup into the page."""
|
|
300
|
+
server = _start_loopback_server(0)
|
|
301
|
+
try:
|
|
302
|
+
port = server.server_address[1]
|
|
303
|
+
url = (
|
|
304
|
+
f"http://127.0.0.1:{port}/oauth/callback"
|
|
305
|
+
"?error=access_denied&error_description=%3Cscript%3Ealert(1)%3C/script%3E"
|
|
306
|
+
)
|
|
307
|
+
capture = _serve_one(url, server)
|
|
308
|
+
finally:
|
|
309
|
+
server.server_close()
|
|
310
|
+
|
|
311
|
+
assert "<script" not in capture.body
|
|
312
|
+
assert "<script>" in capture.body
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_favicon_request_is_quiet() -> None:
|
|
316
|
+
"""Browsers requesting /favicon.ico get an empty 204, not an error page."""
|
|
317
|
+
server = _start_loopback_server(0)
|
|
318
|
+
try:
|
|
319
|
+
port = server.server_address[1]
|
|
320
|
+
capture = _serve_one(f"http://127.0.0.1:{port}/favicon.ico", server)
|
|
321
|
+
finally:
|
|
322
|
+
server.server_close()
|
|
323
|
+
|
|
324
|
+
assert capture.status == 204
|
|
325
|
+
assert capture.body == ""
|
|
326
|
+
|
|
327
|
+
|
|
209
328
|
def test_callback_missing_state() -> None:
|
|
210
329
|
"""A callback without a state parameter raises AuthError."""
|
|
211
330
|
server = _start_loopback_server(0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|