haru-cli 0.1.2__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.
Files changed (94) hide show
  1. {haru_cli-0.1.2 → haru_cli-0.1.4}/CHANGELOG.md +29 -0
  2. {haru_cli-0.1.2 → haru_cli-0.1.4}/PKG-INFO +8 -3
  3. {haru_cli-0.1.2 → haru_cli-0.1.4}/README.md +7 -2
  4. {haru_cli-0.1.2 → haru_cli-0.1.4}/config/haru.yaml +4 -2
  5. {haru_cli-0.1.2 → haru_cli-0.1.4}/pyproject.toml +1 -1
  6. haru_cli-0.1.4/src/haru/auth/identity.py +169 -0
  7. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/auth/session.py +24 -8
  8. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/auth/sso.py +126 -6
  9. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/config.py +2 -6
  10. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/login.py +24 -2
  11. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/schema.py +9 -3
  12. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/haru.yaml +4 -2
  13. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_cli.py +14 -1
  14. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_config_command.py +0 -2
  15. haru_cli-0.1.4/tests/unit/test_identity.py +172 -0
  16. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_session.py +60 -4
  17. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_sso.py +120 -1
  18. {haru_cli-0.1.2 → haru_cli-0.1.4}/uv.lock +1 -1
  19. {haru_cli-0.1.2 → haru_cli-0.1.4}/.github/workflows/ci.yml +0 -0
  20. {haru_cli-0.1.2 → haru_cli-0.1.4}/.github/workflows/release.yml +0 -0
  21. {haru_cli-0.1.2 → haru_cli-0.1.4}/.gitignore +0 -0
  22. {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/coding-standards.md +0 -0
  23. {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/git-workflow.md +0 -0
  24. {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/product.md +0 -0
  25. {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/security.md +0 -0
  26. {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/structure.md +0 -0
  27. {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/tech.md +0 -0
  28. {haru_cli-0.1.2 → haru_cli-0.1.4}/.kiro/steering/testing.md +0 -0
  29. {haru_cli-0.1.2 → haru_cli-0.1.4}/.pre-commit-config.yaml +0 -0
  30. {haru_cli-0.1.2 → haru_cli-0.1.4}/.python-version +0 -0
  31. {haru_cli-0.1.2 → haru_cli-0.1.4}/CLAUDE.md +0 -0
  32. {haru_cli-0.1.2 → haru_cli-0.1.4}/LICENSE +0 -0
  33. {haru_cli-0.1.2 → haru_cli-0.1.4}/config/agents.yaml +0 -0
  34. {haru_cli-0.1.2 → haru_cli-0.1.4}/config/guardrails.yaml +0 -0
  35. {haru_cli-0.1.2 → haru_cli-0.1.4}/config/logging.yaml +0 -0
  36. {haru_cli-0.1.2 → haru_cli-0.1.4}/config/mcp.yaml +0 -0
  37. {haru_cli-0.1.2 → haru_cli-0.1.4}/config/models.yaml +0 -0
  38. {haru_cli-0.1.2 → haru_cli-0.1.4}/config/prompts/researcher.md +0 -0
  39. {haru_cli-0.1.2 → haru_cli-0.1.4}/config/prompts/supervisor.md +0 -0
  40. {haru_cli-0.1.2 → haru_cli-0.1.4}/config/prompts/writer.md +0 -0
  41. {haru_cli-0.1.2 → haru_cli-0.1.4}/docs/configuration.md +0 -0
  42. {haru_cli-0.1.2 → haru_cli-0.1.4}/renovate.json +0 -0
  43. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/__init__.py +0 -0
  44. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/__main__.py +0 -0
  45. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/agents/__init__.py +0 -0
  46. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/agents/factory.py +0 -0
  47. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/agents/orchestration.py +0 -0
  48. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/auth/__init__.py +0 -0
  49. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/auth/cache.py +0 -0
  50. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/auth/pkce.py +0 -0
  51. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/cli.py +0 -0
  52. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/__init__.py +0 -0
  53. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/agents.py +0 -0
  54. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/chat.py +0 -0
  55. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/run.py +0 -0
  56. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/session.py +0 -0
  57. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/commands/streaming.py +0 -0
  58. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/__init__.py +0 -0
  59. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/loader.py +0 -0
  60. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/__init__.py +0 -0
  61. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/agents.yaml +0 -0
  62. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/logging.yaml +0 -0
  63. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/mcp.yaml +0 -0
  64. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/models.yaml +0 -0
  65. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/prompts/researcher.md +0 -0
  66. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/prompts/supervisor.md +0 -0
  67. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/config/templates/prompts/writer.md +0 -0
  68. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/errors.py +0 -0
  69. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/models/__init__.py +0 -0
  70. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/models/bedrock.py +0 -0
  71. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/observability/__init__.py +0 -0
  72. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/observability/guardrails.py +0 -0
  73. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/observability/telemetry.py +0 -0
  74. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/sessions/__init__.py +0 -0
  75. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/sessions/manager.py +0 -0
  76. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/steering/__init__.py +0 -0
  77. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/steering/prompts.py +0 -0
  78. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/tools/__init__.py +0 -0
  79. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/tools/mcp.py +0 -0
  80. {haru_cli-0.1.2 → haru_cli-0.1.4}/src/haru/tools/registry.py +0 -0
  81. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/conftest.py +0 -0
  82. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/integration/test_packaging.py +0 -0
  83. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_agent_factory.py +0 -0
  84. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_bedrock.py +0 -0
  85. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_cache.py +0 -0
  86. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_config_loader.py +0 -0
  87. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_guardrails.py +0 -0
  88. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_main.py +0 -0
  89. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_mcp.py +0 -0
  90. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_orchestration.py +0 -0
  91. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_pkce.py +0 -0
  92. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_prompts.py +0 -0
  93. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_sessions.py +0 -0
  94. {haru_cli-0.1.2 → haru_cli-0.1.4}/tests/unit/test_telemetry.py +0 -0
@@ -9,6 +9,35 @@ 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
+
28
+ - Browser-only login: `haru login` now discovers your accessible AWS
29
+ accounts and permission-set roles from Identity Center after sign-in
30
+ (auto-selected when unambiguous, prompted otherwise) and remembers the
31
+ choice — no IAM role name or account id env var needed upfront.
32
+ `auth.sso.account_id`/`role_name` remain available as optional pins.
33
+
34
+ ### Changed
35
+
36
+ - `haru config init` no longer asks for an IAM role name; the
37
+ `HARU_AWS_ACCOUNT_ID` step is gone from the quickstart.
38
+
39
+ ### Added
40
+
12
41
  - Sampling controls (`temperature`, `top_p`, `top_k`, `seed`) across three
13
42
  surfaces: model entries, per-agent `sampling:` blocks, and CLI/REPL
14
43
  overrides (`--temperature/--top-p/--top-k/--seed`, `/sampling`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haru-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Secure, scriptable CLI for Amazon Bedrock Claude models via the AWS Strands Agents SDK.
5
5
  Author: haru-cli maintainers
6
6
  License-Expression: Apache-2.0
@@ -84,12 +84,17 @@ Inside `haru chat`, slash commands switch targets mid-session:
84
84
 
85
85
  ```bash
86
86
  haru config init # writes ~/.config/haru (asks for your SSO start URL)
87
- export HARU_AWS_ACCOUNT_ID=<your AWS account id>
88
- haru login
87
+ haru login # browser sign-in; account and role discovered for you
89
88
  haru run "Summarise the Converse API in two sentences."
90
89
  haru chat --agent supervisor --session-id demo
91
90
  ```
92
91
 
92
+ `haru login` is browser-only: after Identity Center sign-in, your accessible
93
+ AWS accounts and permission-set roles are discovered automatically (like
94
+ `aws configure sso`), auto-selected when there is exactly one, prompted
95
+ otherwise, and remembered for later sessions. Pin `auth.sso.account_id` /
96
+ `role_name` in configuration only if you want to skip the discovery.
97
+
93
98
  Authentication uses the same PKCE authorization-code flow as `aws sso login`,
94
99
  caching tokens in the botocore-compatible schema under `~/.aws/sso/cache`
95
100
  (0600) so standard AWS tooling can consume and refresh the same cache. Access
@@ -56,12 +56,17 @@ Inside `haru chat`, slash commands switch targets mid-session:
56
56
 
57
57
  ```bash
58
58
  haru config init # writes ~/.config/haru (asks for your SSO start URL)
59
- export HARU_AWS_ACCOUNT_ID=<your AWS account id>
60
- haru login
59
+ haru login # browser sign-in; account and role discovered for you
61
60
  haru run "Summarise the Converse API in two sentences."
62
61
  haru chat --agent supervisor --session-id demo
63
62
  ```
64
63
 
64
+ `haru login` is browser-only: after Identity Center sign-in, your accessible
65
+ AWS accounts and permission-set roles are discovered automatically (like
66
+ `aws configure sso`), auto-selected when there is exactly one, prompted
67
+ otherwise, and remembered for later sessions. Pin `auth.sso.account_id` /
68
+ `role_name` in configuration only if you want to skip the discovery.
69
+
65
70
  Authentication uses the same PKCE authorization-code flow as `aws sso login`,
66
71
  caching tokens in the botocore-compatible schema under `~/.aws/sso/cache`
67
72
  (0600) so standard AWS tooling can consume and refresh the same cache. Access
@@ -8,8 +8,10 @@ auth:
8
8
  registration_scopes:
9
9
  - sso:account:access
10
10
  client_name: haru-cli
11
- account_id_env: HARU_AWS_ACCOUNT_ID
12
- role_name: HaruBedrockInvoke
11
+ # Optional pins. When unset, your AWS account and role are discovered
12
+ # during 'haru login' (browser sign-in) and remembered.
13
+ # account_id: "123456789012"
14
+ # role_name: HaruBedrockInvoke
13
15
  callback_port: 0 # 0 selects an ephemeral loopback port
14
16
  browser: true
15
17
  bedrock_region: us-east-1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "haru-cli"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Secure, scriptable CLI for Amazon Bedrock Claude models via the AWS Strands Agents SDK."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,169 @@
1
+ """Discover and persist the AWS account and role behind an SSO sign-in.
2
+
3
+ Kiro-style browser sign-in needs no upfront AWS knowledge: after the token
4
+ exchange, the accessible accounts and permission-set roles are listed from
5
+ IAM Identity Center (as ``aws configure sso`` does), auto-selected when there
6
+ is exactly one, and persisted next to the token cache for later sessions.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import boto3
17
+ from botocore.exceptions import ClientError
18
+
19
+ from haru.auth.cache import cache_path
20
+ from haru.auth.sso import SsoToken
21
+ from haru.config.schema import SsoConfig
22
+ from haru.errors import AuthError
23
+
24
+ Chooser = Callable[[str, list[str]], int]
25
+ """Callback picking one option: (question, labels) -> chosen index."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SelectedIdentity:
30
+ """The AWS account and role selected for Bedrock access."""
31
+
32
+ account_id: str
33
+ role_name: str
34
+ account_name: str | None = None
35
+
36
+
37
+ def identity_path(start_url: str, cache_dir: Path | None = None) -> Path:
38
+ """Return the persisted-identity path for ``start_url``."""
39
+ token_path = cache_path(start_url, cache_dir)
40
+ return token_path.with_name(f"{token_path.stem}-haru-identity.json")
41
+
42
+
43
+ def write_identity(
44
+ identity: SelectedIdentity, start_url: str, cache_dir: Path | None = None
45
+ ) -> Path:
46
+ """Persist the selected identity next to the token cache."""
47
+ path = identity_path(start_url, cache_dir)
48
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
49
+ payload = {
50
+ "accountId": identity.account_id,
51
+ "roleName": identity.role_name,
52
+ "accountName": identity.account_name,
53
+ }
54
+ path.write_text(json.dumps(payload), encoding="utf-8")
55
+ path.chmod(0o600)
56
+ return path
57
+
58
+
59
+ def read_identity(start_url: str, cache_dir: Path | None = None) -> SelectedIdentity | None:
60
+ """Read the persisted identity; None when absent or unreadable."""
61
+ path = identity_path(start_url, cache_dir)
62
+ if not path.is_file():
63
+ return None
64
+ try:
65
+ payload = json.loads(path.read_text(encoding="utf-8"))
66
+ return SelectedIdentity(
67
+ account_id=payload["accountId"],
68
+ role_name=payload["roleName"],
69
+ account_name=payload.get("accountName"),
70
+ )
71
+ except (json.JSONDecodeError, KeyError):
72
+ return None
73
+
74
+
75
+ def select_identity(
76
+ sso_cfg: SsoConfig,
77
+ token: SsoToken,
78
+ *,
79
+ sso_client: Any | None = None,
80
+ cache_dir: Path | None = None,
81
+ chooser: Chooser | None = None,
82
+ ) -> SelectedIdentity:
83
+ """Resolve the account and role for this sign-in and persist the choice.
84
+
85
+ Configuration pins win (``account_id``/``role_name``, or the account id
86
+ from ``account_id_env``); anything unpinned is discovered from Identity
87
+ Center. With multiple choices and no ``chooser``, raises AuthError.
88
+ """
89
+ if sso_client is None:
90
+ sso_client = boto3.Session().client("sso", region_name=sso_cfg.sso_region)
91
+
92
+ account_id = _pinned_account(sso_cfg)
93
+ account_name: str | None = None
94
+ if account_id is None:
95
+ accounts = _list_accounts(sso_client, token.access_token)
96
+ chosen = _choose(
97
+ "Choose the AWS account to use",
98
+ accounts,
99
+ lambda account: f"{account['accountId']} {account.get('accountName', '')}".strip(),
100
+ chooser,
101
+ )
102
+ account_id = chosen["accountId"]
103
+ account_name = chosen.get("accountName")
104
+
105
+ role_name = sso_cfg.role_name
106
+ if role_name is None:
107
+ roles = _list_roles(sso_client, token.access_token, account_id)
108
+ role_name = _choose(f"Choose the role for account {account_id}", roles, str, chooser)
109
+
110
+ identity = SelectedIdentity(
111
+ account_id=account_id, role_name=role_name, account_name=account_name
112
+ )
113
+ write_identity(identity, sso_cfg.start_url, cache_dir)
114
+ return identity
115
+
116
+
117
+ def _pinned_account(sso_cfg: SsoConfig) -> str | None:
118
+ if sso_cfg.account_id is not None:
119
+ return sso_cfg.account_id
120
+ if sso_cfg.account_id_env is not None:
121
+ return os.environ.get(sso_cfg.account_id_env)
122
+ return None
123
+
124
+
125
+ def _list_accounts(sso_client: Any, access_token: str) -> list[dict[str, Any]]:
126
+ try:
127
+ paginator = sso_client.get_paginator("list_accounts")
128
+ accounts = [
129
+ account
130
+ for page in paginator.paginate(accessToken=access_token)
131
+ for account in page.get("accountList", [])
132
+ ]
133
+ except ClientError as exc:
134
+ raise AuthError(f"Listing accessible AWS accounts failed: {exc}") from exc
135
+ if not accounts:
136
+ raise AuthError("This sign-in grants access to no AWS accounts; contact your admin")
137
+ return sorted(accounts, key=lambda account: str(account["accountId"]))
138
+
139
+
140
+ def _list_roles(sso_client: Any, access_token: str, account_id: str) -> list[str]:
141
+ try:
142
+ paginator = sso_client.get_paginator("list_account_roles")
143
+ roles = [
144
+ role["roleName"]
145
+ for page in paginator.paginate(accessToken=access_token, accountId=account_id)
146
+ for role in page.get("roleList", [])
147
+ ]
148
+ except ClientError as exc:
149
+ raise AuthError(f"Listing roles for account {account_id} failed: {exc}") from exc
150
+ if not roles:
151
+ raise AuthError(f"No roles are available in account {account_id}; contact your admin")
152
+ return sorted(roles)
153
+
154
+
155
+ def _choose[Item](
156
+ question: str,
157
+ options: list[Item],
158
+ label: Callable[[Item], str],
159
+ chooser: Chooser | None,
160
+ ) -> Item:
161
+ if len(options) == 1:
162
+ return options[0]
163
+ if chooser is None:
164
+ raise AuthError(
165
+ f"{question}: multiple options available; re-run 'haru login' interactively"
166
+ " or pin the choice in configuration"
167
+ )
168
+ index = chooser(question, [label(option) for option in options])
169
+ return options[index]
@@ -14,9 +14,10 @@ from botocore.exceptions import (
14
14
  )
15
15
 
16
16
  from haru.auth.cache import read_token_cache, write_token_cache
17
+ from haru.auth.identity import read_identity
17
18
  from haru.auth.sso import SsoToken
18
- from haru.config.schema import AuthConfig
19
- from haru.errors import AuthExpiredError, ConfigError
19
+ from haru.config.schema import AuthConfig, SsoConfig
20
+ from haru.errors import AuthExpiredError
20
21
 
21
22
  _REFRESH_WINDOW = timedelta(minutes=15)
22
23
  _RELOGIN_HINT = "run 'haru login'"
@@ -42,16 +43,12 @@ def build_boto3_session(
42
43
  if token.expires_at <= datetime.now(UTC) + _REFRESH_WINDOW:
43
44
  token = _refresh_token(config, token, oidc=oidc, cache_dir=cache_dir)
44
45
 
45
- account_id = os.environ.get(sso.account_id_env)
46
- if account_id is None:
47
- raise ConfigError(
48
- f"Environment variable {sso.account_id_env!r} must hold the target AWS account id"
49
- )
46
+ account_id, role_name = _resolve_identity(sso, cache_dir)
50
47
  if sso_client is None:
51
48
  sso_client = boto3.Session().client("sso", region_name=sso.sso_region)
52
49
  try:
53
50
  response = sso_client.get_role_credentials(
54
- roleName=sso.role_name, accountId=account_id, accessToken=token.access_token
51
+ roleName=role_name, accountId=account_id, accessToken=token.access_token
55
52
  )
56
53
  except (ClientError, SSOTokenLoadError, UnauthorizedSSOTokenError, TokenRetrievalError) as exc:
57
54
  raise AuthExpiredError(f"SSO credentials rejected; {_RELOGIN_HINT}") from exc
@@ -65,6 +62,25 @@ def build_boto3_session(
65
62
  )
66
63
 
67
64
 
65
+ def _resolve_identity(sso: SsoConfig, cache_dir: Path | None) -> tuple[str, str]:
66
+ """Resolve the account id and role: config pins, env var, then the login choice."""
67
+ stored = read_identity(sso.start_url, cache_dir)
68
+
69
+ account_id = sso.account_id
70
+ if account_id is None and sso.account_id_env is not None:
71
+ account_id = os.environ.get(sso.account_id_env)
72
+ if account_id is None and stored is not None:
73
+ account_id = stored.account_id
74
+
75
+ role_name = sso.role_name
76
+ if role_name is None and stored is not None:
77
+ role_name = stored.role_name
78
+
79
+ if account_id is None or role_name is None:
80
+ raise AuthExpiredError(f"No AWS account/role selected yet; {_RELOGIN_HINT}")
81
+ return account_id, role_name
82
+
83
+
68
84
  def _refresh_token(
69
85
  config: AuthConfig, token: SsoToken, *, oidc: Any | None, cache_dir: Path | None
70
86
  ) -> SsoToken:
@@ -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
- _CALLBACK_RESPONSE = (
30
- b"<html><body><p>Sign-in complete. You can close this window and return to haru.</p>"
31
- b"</body></html>"
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
- server.callback_params = parse_qs(parsed.query)
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(_CALLBACK_RESPONSE)
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:
@@ -65,7 +65,6 @@ def config() -> None:
65
65
  "--sso-region", default="us-east-1", prompt="Identity Center region", show_default=True
66
66
  )
67
67
  @click.option("--bedrock-region", default="us-east-1", prompt="Bedrock region", show_default=True)
68
- @click.option("--role-name", default="HaruBedrockInvoke", prompt="IAM role name", show_default=True)
69
68
  @click.option(
70
69
  "--guardrail-id",
71
70
  default="",
@@ -78,7 +77,6 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
78
77
  start_url: str,
79
78
  sso_region: str,
80
79
  bedrock_region: str,
81
- role_name: str,
82
80
  guardrail_id: str,
83
81
  force: bool,
84
82
  ) -> None:
@@ -92,7 +90,6 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
92
90
  "__START_URL__": start_url,
93
91
  "__SSO_REGION__": sso_region,
94
92
  "__BEDROCK_REGION__": bedrock_region,
95
- "__ROLE_NAME__": role_name,
96
93
  }
97
94
  templates = resources.files("haru.config.templates")
98
95
  (target / "prompts").mkdir(parents=True, exist_ok=True)
@@ -113,9 +110,8 @@ def init( # noqa: PLR0913, PLR0917 - one option per prompted setting; Click pas
113
110
 
114
111
  click.echo(f"Configuration written to {target}.")
115
112
  click.echo("Next steps:")
116
- click.echo(" 1. export HARU_AWS_ACCOUNT_ID=<your AWS account id>")
117
- click.echo(" 2. haru login")
118
- click.echo(" 3. haru chat")
113
+ click.echo(" 1. haru login (browser sign-in; your account and role are discovered)")
114
+ click.echo(" 2. haru chat")
119
115
 
120
116
 
121
117
  @config.command()
@@ -1,4 +1,9 @@
1
- """The ``haru login`` command: interactive IAM Identity Center sign-in."""
1
+ """The ``haru login`` command: interactive IAM Identity Center sign-in.
2
+
3
+ Browser-only sign-in: no IAM role or account id needs to be known upfront.
4
+ After the token exchange, the accessible accounts and roles are discovered
5
+ from Identity Center, auto-selected when unambiguous, and remembered.
6
+ """
2
7
 
3
8
  import webbrowser
4
9
  from pathlib import Path
@@ -6,18 +11,28 @@ from pathlib import Path
6
11
  import click
7
12
 
8
13
  from haru.auth.cache import write_token_cache
14
+ from haru.auth.identity import select_identity
9
15
  from haru.auth.sso import run_login
10
16
  from haru.config import load_config
11
17
  from haru.errors import HaruError
12
18
 
13
19
 
20
+ def _prompt_choice(question: str, labels: list[str]) -> int:
21
+ """Interactive numbered chooser for accounts and roles."""
22
+ click.echo(f"{question}:")
23
+ for number, label in enumerate(labels, start=1):
24
+ click.echo(f" {number}. {label}")
25
+ choice: int = click.prompt("Enter a number", type=click.IntRange(1, len(labels)))
26
+ return choice - 1
27
+
28
+
14
29
  @click.command()
15
30
  @click.option(
16
31
  "--config",
17
32
  "config_path",
18
33
  type=click.Path(exists=True, dir_okay=False, path_type=Path),
19
34
  default=None,
20
- help="Path to haru.yaml (default: config/haru.yaml).",
35
+ help="Path to haru.yaml (default: ./config/haru.yaml, then ~/.config/haru/haru.yaml).",
21
36
  )
22
37
  def login(config_path: Path | None) -> None:
23
38
  """Sign in to AWS IAM Identity Center via the browser (PKCE)."""
@@ -33,6 +48,13 @@ def login(config_path: Path | None) -> None:
33
48
 
34
49
  token = run_login(auth, opener=_open)
35
50
  path = write_token_cache(token, auth.sso.start_url, auth.sso.sso_region)
51
+ identity = select_identity(auth.sso, token, chooser=_prompt_choice)
36
52
  except HaruError as exc:
37
53
  raise click.ClickException(str(exc)) from exc
38
54
  click.echo(f"Login successful. Token cached at {path}.")
55
+ account_label = (
56
+ f"{identity.account_id} ({identity.account_name})"
57
+ if identity.account_name
58
+ else identity.account_id
59
+ )
60
+ click.echo(f"Using account {account_label}, role {identity.role_name}.")
@@ -24,14 +24,20 @@ class AppConfig(_FrozenModel):
24
24
 
25
25
 
26
26
  class SsoConfig(_FrozenModel):
27
- """IAM Identity Center (SSO) login settings."""
27
+ """IAM Identity Center (SSO) login settings.
28
+
29
+ ``account_id`` and ``role_name`` are optional: when unset, the account
30
+ and permission-set role are discovered interactively during
31
+ ``haru login`` (browser-only sign-in, like the AWS CLI's SSO flow).
32
+ """
28
33
 
29
34
  start_url: str
30
35
  sso_region: str
31
36
  registration_scopes: tuple[str, ...] = ("sso:account:access",)
32
37
  client_name: str = "haru-cli"
33
- account_id_env: str
34
- role_name: str
38
+ account_id: str | None = None
39
+ account_id_env: str | None = None
40
+ role_name: str | None = None
35
41
  callback_port: int = Field(default=0, ge=0, le=65535)
36
42
  browser: bool = True
37
43
 
@@ -8,8 +8,10 @@ auth:
8
8
  registration_scopes:
9
9
  - sso:account:access
10
10
  client_name: haru-cli
11
- account_id_env: HARU_AWS_ACCOUNT_ID
12
- role_name: __ROLE_NAME__
11
+ # Optional pins. When unset, your AWS account and role are discovered
12
+ # during 'haru login' (browser sign-in) and remembered.
13
+ # account_id: "123456789012"
14
+ # role_name: BedrockAccess
13
15
  callback_port: 0 # 0 selects an ephemeral loopback port
14
16
  browser: true
15
17
  bedrock_region: __BEDROCK_REGION__