cli-login 0.4.0__tar.gz → 0.4.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.
- {cli_login-0.4.0 → cli_login-0.4.1}/PKG-INFO +26 -3
- {cli_login-0.4.0 → cli_login-0.4.1}/README.md +25 -2
- {cli_login-0.4.0 → cli_login-0.4.1}/docs/design.md +4 -1
- {cli_login-0.4.0 → cli_login-0.4.1}/docs/protocol.md +5 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/remote_sign_in.feature +2 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/remote_sign_in_steps.py +15 -1
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_agent_steps.py +16 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_agent.feature +5 -0
- cli_login-0.4.1/project/events/2026-10-31T15-05-12.601Z__80377f48-3218-4a3d-9f7e-9c4db44de2f0.json +23 -0
- cli_login-0.4.1/project/events/2026-10-31T15-27-42.778Z__65bd906f-6801-4df1-ae7b-2112c3629b32.json +12 -0
- cli_login-0.4.1/project/events/2026-10-31T15-27-43.836Z__5da8b7b7-4903-4767-a638-3cb0d95c32a0.json +12 -0
- cli_login-0.4.1/project/issues/cli-login-79ca92d5-4534-4bea-bfe1-aaeaa5037b49.json +28 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/pyproject.toml +1 -1
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/__init__.py +1 -1
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/handler_test.py +25 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/pages_test.py +44 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/remote.py +32 -2
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/remote_test.py +54 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/agent.py +12 -5
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/agent_test.py +27 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/.claude/settings.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/.cursorignore +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/.github/workflows/release.yml +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/.github/workflows/tests.yml +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/.gitignore +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/.kanbus.yml +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/.vscode/settings.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/AGENTS.md +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/CONTRIBUTING_AGENT.md +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/CONTRIBUTING_AGENT.template.md +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/LICENSE +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/environment.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/locks.feature +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/login_session.feature +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/oauth_provider.feature +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/locks_steps.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/login_session_steps.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/oauth_provider_steps.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/store_common_steps.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_kernel_keyring_steps.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_macos_steps.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_secret_service_steps.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_selection_steps.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_windows_steps.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/target_steps.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_agent_windows.feature +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_kernel_keyring.feature +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_macos.feature +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_secret_service.feature +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_selection.feature +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_windows.feature +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/features/target.feature +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/infra/broker.yaml +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/infra/broker_template_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/AGENTS.md +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.293Z__b9260b7c-7ee0-4df4-a3e0-618bf6410f7f.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.309Z__8329b8bd-f933-4597-b6ef-11d3b9e83819.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.319Z__02962f12-cc16-413f-94b1-9ac250cfe43a.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.329Z__78d07504-8941-45fc-aa87-a883260179b6.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.339Z__e2e9e373-9425-47cc-ab2e-97bd17a904b3.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.350Z__811313ad-5bef-4918-a09b-0c6fbee06c27.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.363Z__92385224-7ace-40cd-9f1f-09f3a491c086.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-53-42.529Z__b801b764-9abb-4714-9c9a-57f98ec6b120.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-43-05.049Z__5f67cee7-ed11-4602-b91a-cc0dc054a000.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-43-05.062Z__447020e8-b028-4fbe-9207-869e9aff8ee6.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-54-51.436Z__48a79b75-ead6-4a2d-a31c-bb9cedbaaabc.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-54-51.454Z__222fa29e-fada-4e25-a64d-e259c99cbb24.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-54-51.463Z__0f87a247-a94e-4611-a7a5-0a80f16b50a0.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-56-05.842Z__3faa8c4c-7c22-41ab-b86c-2e12e622eb6a.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-56-05.852Z__9c5e7de9-0269-47d0-9dab-e58850e21ac9.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.140Z__284dc920-8b9e-47e3-b924-4233c908a54e.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.154Z__08903f6c-aab5-4ea7-877c-cc67ed9cf832.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.163Z__9d412c33-67bf-4bd4-83af-0c55e972f94e.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.173Z__ed2149f3-4c1f-43e4-b38d-0f9b295a0234.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.183Z__8cc06e66-99f6-4529-b312-a70cbaff1ca9.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.193Z__3ee6c0c7-52ca-40c3-b7d2-5e0950b0dce2.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-58-11.250Z__57d95af6-0bf0-4cc6-b610-82f4c40fbff1.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-00-38.672Z__b391bfe3-c3b0-4a9d-8839-1381a6577b91.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-00-38.686Z__153c3c9d-7b02-43af-8d50-888e8a9d965d.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-07-40.329Z__ba26d852-684d-4347-bae5-d347cb69fddd.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-07-40.337Z__602e30d8-8ef7-40f3-9fe6-505e735f3b89.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-10-43.690Z__c2398383-7bf7-437e-8b4c-7aba9633cfbc.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-10-44.027Z__0644b779-33ed-49c7-91dc-166760f42aae.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-28-43.772Z__f550a131-02c7-44c0-b76a-3694222d74a9.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T14-01-17.888Z__bf995622-7f63-4936-9891-4cebf7208cf5.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T14-01-17.900Z__e9675700-922f-449e-8b9f-9779917ce373.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/AGENTS.md +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/events/DO_NOT_EDIT +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/AGENTS.md +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/DO_NOT_EDIT +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-04de9a1b-0c3e-4589-b9b5-ae30cd3cdd18.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-1b4a091f-2bfc-405c-be6e-b787b4bb4696.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-31750ff1-8d69-4b03-b953-82da220c1a9f.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-361d5c51-7951-4e46-9c57-a64cb6f8058f.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-85e67311-6ca7-4c57-9e44-4d414ebc618f.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-96ed2301-f038-4d76-84bb-b670184ab538.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-9a02d68d-d3ff-4dfd-a319-2c411f05fddd.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-cccc972a-7df2-4795-9f9b-d8805dfc142e.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-ff76cbbc-d47b-49d5-91e1-4aa2ae15e99f.json +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/wiki/index.md +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/project/wiki/whats-next.md +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/scripts/build_broker.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/__init___test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/__init__.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/handler.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/pages.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/throttle.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/transactions.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/transactions_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/concurrency_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/errors.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/errors_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/locks.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/locks_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/oauth.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/oauth_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/session.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/session_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/__init__.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/kernel_keyring.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/kernel_keyring_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/macos.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/macos_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/secret_service.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/secret_service_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/stores_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/windows.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/windows_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/target.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/target_test.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/testing.py +0 -0
- {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/testing_processes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cli-login
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Browser sign-in for CLIs without long-lived keys: PKCE, remote approval, secure storage and renewal.
|
|
5
5
|
Author: Anthus AI
|
|
6
6
|
License: MIT
|
|
@@ -222,6 +222,26 @@ Each product deployment runs one broker, built from `infra/broker.yaml`.
|
|
|
222
222
|
The broker and the approval pages must run the same cli-login version. Products should pin an exact version, since
|
|
223
223
|
releases before 1.0 may change behaviour.
|
|
224
224
|
|
|
225
|
+
## The product's app client
|
|
226
|
+
|
|
227
|
+
cli-login creates no Cognito client; each product signs its CLI in with an app client it defines. Use these settings
|
|
228
|
+
unless the product has a reviewed reason not to:
|
|
229
|
+
|
|
230
|
+
| Setting | Default |
|
|
231
|
+
| --- | --- |
|
|
232
|
+
| OAuth flow | Authorization code only, with PKCE (S256) |
|
|
233
|
+
| Client secret | None (a public client) |
|
|
234
|
+
| Explicit auth flows | `ALLOW_REFRESH_TOKEN_AUTH` only |
|
|
235
|
+
| Refresh token | 7 days |
|
|
236
|
+
| Access and ID tokens | 60 minutes |
|
|
237
|
+
| Token revocation | On, so `logout()` can revoke the refresh token |
|
|
238
|
+
| Callbacks | Exact URLs: one fixed loopback port for local sign-in and `https://<product-domain>/cli/callback` for remote sign-in |
|
|
239
|
+
|
|
240
|
+
Seven days lets an agent on a cloud machine keep working for a week without a person signing in again, while a
|
|
241
|
+
lost or leaked refresh token stops working within the week. Access tokens stay short, so an API sees a revocation
|
|
242
|
+
within an hour. Cognito matches callbacks exactly, with no port wildcard, so the loopback port is fixed and
|
|
243
|
+
registered.
|
|
244
|
+
|
|
225
245
|
## Where sessions are stored
|
|
226
246
|
|
|
227
247
|
Sessions live only in a secure store, tried in this order. There is no plaintext fallback: if nothing is
|
|
@@ -240,7 +260,10 @@ and tests). Any other value is refused.
|
|
|
240
260
|
### Cloud machines and containers
|
|
241
261
|
|
|
242
262
|
A cloud machine usually has no desktop keyring, and Docker's default seccomp profile blocks the kernel keyring
|
|
243
|
-
calls. Start the memory agent once per product, then sign in again
|
|
263
|
+
calls. Start the memory agent once per product, then sign in again. The error message prints the exact command,
|
|
264
|
+
with the full path of the interpreter that has `cli-login` installed; use that one. A bare `python` cannot import
|
|
265
|
+
the library when a product installs it in a private environment (`pipx`, `uv tool`), so in these examples
|
|
266
|
+
`python` stands for that interpreter:
|
|
244
267
|
|
|
245
268
|
```bash
|
|
246
269
|
python -m cli_login.stores.agent serve --namespace <product-namespace> --background
|
|
@@ -276,7 +299,7 @@ as with ssh-agent.
|
|
|
276
299
|
requester label, the matching code the approver must confirm, and `expected_email` narrow the risk. They do not
|
|
277
300
|
remove it, and the CLI always prints the identity it signed in as.
|
|
278
301
|
- **Tokens can leak.** They are short-lived and renewable, and logout revokes the refresh token. A stolen refresh
|
|
279
|
-
token works until it
|
|
302
|
+
token works until it is revoked or expires (7 days with the default app client).
|
|
280
303
|
- **The broker never holds a token.** It holds an authorization code until the right CLI collects it, and the
|
|
281
304
|
code is useless without the verifier.
|
|
282
305
|
- **Cognito only, for now.** The library targets Amazon Cognito user pools with the authorization-code flow and PKCE.
|
|
@@ -206,6 +206,26 @@ Each product deployment runs one broker, built from `infra/broker.yaml`.
|
|
|
206
206
|
The broker and the approval pages must run the same cli-login version. Products should pin an exact version, since
|
|
207
207
|
releases before 1.0 may change behaviour.
|
|
208
208
|
|
|
209
|
+
## The product's app client
|
|
210
|
+
|
|
211
|
+
cli-login creates no Cognito client; each product signs its CLI in with an app client it defines. Use these settings
|
|
212
|
+
unless the product has a reviewed reason not to:
|
|
213
|
+
|
|
214
|
+
| Setting | Default |
|
|
215
|
+
| --- | --- |
|
|
216
|
+
| OAuth flow | Authorization code only, with PKCE (S256) |
|
|
217
|
+
| Client secret | None (a public client) |
|
|
218
|
+
| Explicit auth flows | `ALLOW_REFRESH_TOKEN_AUTH` only |
|
|
219
|
+
| Refresh token | 7 days |
|
|
220
|
+
| Access and ID tokens | 60 minutes |
|
|
221
|
+
| Token revocation | On, so `logout()` can revoke the refresh token |
|
|
222
|
+
| Callbacks | Exact URLs: one fixed loopback port for local sign-in and `https://<product-domain>/cli/callback` for remote sign-in |
|
|
223
|
+
|
|
224
|
+
Seven days lets an agent on a cloud machine keep working for a week without a person signing in again, while a
|
|
225
|
+
lost or leaked refresh token stops working within the week. Access tokens stay short, so an API sees a revocation
|
|
226
|
+
within an hour. Cognito matches callbacks exactly, with no port wildcard, so the loopback port is fixed and
|
|
227
|
+
registered.
|
|
228
|
+
|
|
209
229
|
## Where sessions are stored
|
|
210
230
|
|
|
211
231
|
Sessions live only in a secure store, tried in this order. There is no plaintext fallback: if nothing is
|
|
@@ -224,7 +244,10 @@ and tests). Any other value is refused.
|
|
|
224
244
|
### Cloud machines and containers
|
|
225
245
|
|
|
226
246
|
A cloud machine usually has no desktop keyring, and Docker's default seccomp profile blocks the kernel keyring
|
|
227
|
-
calls. Start the memory agent once per product, then sign in again
|
|
247
|
+
calls. Start the memory agent once per product, then sign in again. The error message prints the exact command,
|
|
248
|
+
with the full path of the interpreter that has `cli-login` installed; use that one. A bare `python` cannot import
|
|
249
|
+
the library when a product installs it in a private environment (`pipx`, `uv tool`), so in these examples
|
|
250
|
+
`python` stands for that interpreter:
|
|
228
251
|
|
|
229
252
|
```bash
|
|
230
253
|
python -m cli_login.stores.agent serve --namespace <product-namespace> --background
|
|
@@ -260,7 +283,7 @@ as with ssh-agent.
|
|
|
260
283
|
requester label, the matching code the approver must confirm, and `expected_email` narrow the risk. They do not
|
|
261
284
|
remove it, and the CLI always prints the identity it signed in as.
|
|
262
285
|
- **Tokens can leak.** They are short-lived and renewable, and logout revokes the refresh token. A stolen refresh
|
|
263
|
-
token works until it
|
|
286
|
+
token works until it is revoked or expires (7 days with the default app client).
|
|
264
287
|
- **The broker never holds a token.** It holds an authorization code until the right CLI collects it, and the
|
|
265
288
|
code is useless without the verifier.
|
|
266
289
|
- **Cognito only, for now.** The library targets Amazon Cognito user pools with the authorization-code flow and PKCE.
|
|
@@ -41,7 +41,7 @@ A product describes one **target**:
|
|
|
41
41
|
|
|
42
42
|
The library does the rest:
|
|
43
43
|
|
|
44
|
-
- **Local sign-in.** PKCE (S256), a loopback callback on a
|
|
44
|
+
- **Local sign-in.** PKCE (S256), a loopback callback on a fixed, registered port, state and nonce, and the token
|
|
45
45
|
exchange. It checks each token's issuer, client, `token_use`, expiry and nonce.
|
|
46
46
|
- **Remote sign-in.** It calls the broker's `/start`, prints the approval URL and matching code, polls `/poll`
|
|
47
47
|
at the interval given, waits longer on `slow_down`, and exchanges the code (see the protocol below).
|
|
@@ -152,6 +152,9 @@ What this guarantees:
|
|
|
152
152
|
|
|
153
153
|
## A product's API
|
|
154
154
|
|
|
155
|
+
The product's app client defaults to a 7-day refresh token with 60-minute access and ID tokens, code flow with PKCE
|
|
156
|
+
and no secret; see [the README](../README.md#the-products-app-client).
|
|
157
|
+
|
|
155
158
|
The CLI calls the product's API with `Authorization: Bearer <access token>`. The API verifies the token as it
|
|
156
159
|
would for a browser session. A request that carries both a cookie and a bearer token is refused.
|
|
157
160
|
|
|
@@ -108,6 +108,11 @@ is sent as the OpenID `nonce`, and the ID token must carry it back or the login
|
|
|
108
108
|
`enroll(tokens, state, enrollment)` then runs before anything is stored, and if it raises the tokens are discarded
|
|
109
109
|
and the refresh token is revoked on a best-effort basis.
|
|
110
110
|
|
|
111
|
+
A `nonce_mismatch` stores nothing, tells the person to start the sign-in again, and is never retried with a different
|
|
112
|
+
expected nonce. Its error carries `diagnostics` that tell the cases apart without exposing anything: whether `enroll`
|
|
113
|
+
was given, whether the broker handed over a nonce, and eight-character SHA-256 tags of the transaction, the CLI's own
|
|
114
|
+
nonce, the nonce it expected and the nonce the ID token carried.
|
|
115
|
+
|
|
111
116
|
A network failure during the exchange is **not** retried, because the code may already be spent. The CLI starts
|
|
112
117
|
again.
|
|
113
118
|
|
|
@@ -56,6 +56,8 @@ Feature: Remote sign-in from another device
|
|
|
56
56
|
When the CLI starts a remote sign-in
|
|
57
57
|
And the person approves it but the sign-in provider answers tokens for a different nonce
|
|
58
58
|
Then the CLI reports that the tokens were for a different request
|
|
59
|
+
And the report tells the person to start the sign-in again and that nothing was saved
|
|
60
|
+
And the report carries one-way tags that tell the cases apart and no nonce or token
|
|
59
61
|
And no session is stored
|
|
60
62
|
|
|
61
63
|
Scenario: Someone who reads the link in chat and approves first does not get the session
|
|
@@ -114,7 +114,7 @@ def run_cli(context, on_poll):
|
|
|
114
114
|
try:
|
|
115
115
|
remote_login(Target(), context.stored.append, http=product.http, out=context.printed.append,
|
|
116
116
|
clock=lambda: product.now, sleep=sleep, host="cloud-box",
|
|
117
|
-
expected_email=getattr(context, "expected_email", None))
|
|
117
|
+
expected_email=getattr(context, "expected_email", None), login_command="app login --remote example.test")
|
|
118
118
|
except RemoteLoginError as error:
|
|
119
119
|
context.error = error
|
|
120
120
|
|
|
@@ -289,6 +289,20 @@ def step_nonce(context):
|
|
|
289
289
|
assert context.error.code == "nonce_mismatch"
|
|
290
290
|
|
|
291
291
|
|
|
292
|
+
@then("the report tells the person to start the sign-in again and that nothing was saved")
|
|
293
|
+
def step_nonce_again(context):
|
|
294
|
+
message = str(context.error)
|
|
295
|
+
assert "Start the sign-in again with `app login --remote example.test`." in message and "nothing was saved" in message
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@then("the report carries one-way tags that tell the cases apart and no nonce or token")
|
|
299
|
+
def step_nonce_tags(context):
|
|
300
|
+
diagnostics = context.error.diagnostics
|
|
301
|
+
assert set(diagnostics) == {"enroll", "broker_nonce", "transaction", "own_nonce", "expected_nonce", "received_nonce"}
|
|
302
|
+
assert diagnostics["received_nonce"] != diagnostics["expected_nonce"]
|
|
303
|
+
assert "another" not in str(context.error) and getattr(context, "transaction", "no-transaction") not in str(context.error)
|
|
304
|
+
|
|
305
|
+
|
|
292
306
|
@then("the page says the link is no longer valid")
|
|
293
307
|
def step_gone(context):
|
|
294
308
|
assert context.page.status == 410, context.page.status
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import shlex
|
|
2
3
|
import shutil
|
|
3
4
|
import socket
|
|
4
5
|
import stat
|
|
@@ -219,3 +220,18 @@ def windows(context):
|
|
|
219
220
|
raise AssertionError("expected refusal")
|
|
220
221
|
|
|
221
222
|
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@then("the start-up advice names the running interpreter by its full path")
|
|
226
|
+
def advice_path(context):
|
|
227
|
+
words = shlex.split(agent.agent_command(NS, state_dir=context.state_dir))
|
|
228
|
+
assert words[0] == sys.executable and os.path.isabs(words[0]), words[0]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@then("pasting the start-up advice into a shell starts a running agent")
|
|
232
|
+
def advice_runs(context):
|
|
233
|
+
context.add_cleanup(agent.stop, NS, state_dir=context.state_dir)
|
|
234
|
+
done = subprocess.run(agent.agent_command(NS, state_dir=context.state_dir), shell=True, capture_output=True,
|
|
235
|
+
text=True, timeout=60, check=False)
|
|
236
|
+
assert done.returncode == 0, done.stderr
|
|
237
|
+
assert agent.status(NS, state_dir=context.state_dir) is not None
|
|
@@ -40,6 +40,11 @@ Feature: The memory agent
|
|
|
40
40
|
Given no memory agent is running
|
|
41
41
|
Then a client store is unavailable with guidance mentioning "agent serve"
|
|
42
42
|
|
|
43
|
+
Scenario: The start-up advice works when pasted, even where the library is installed in a private environment
|
|
44
|
+
Given no memory agent is running
|
|
45
|
+
Then the start-up advice names the running interpreter by its full path
|
|
46
|
+
And pasting the start-up advice into a shell starts a running agent
|
|
47
|
+
|
|
43
48
|
Scenario: A second agent for the same product is refused
|
|
44
49
|
Given a running memory agent for the namespace "example-product"
|
|
45
50
|
Then starting another agent for the namespace "example-product" fails
|
cli_login-0.4.1/project/events/2026-10-31T15-05-12.601Z__80377f48-3218-4a3d-9f7e-9c4db44de2f0.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"event_id": "80377f48-3218-4a3d-9f7e-9c4db44de2f0",
|
|
4
|
+
"issue_id": "cli-login-79ca92d5-4534-4bea-bfe1-aaeaa5037b49",
|
|
5
|
+
"event_type": "issue_created",
|
|
6
|
+
"occurred_at": "2026-10-31T15:05:12.601Z",
|
|
7
|
+
"actor_id": "home",
|
|
8
|
+
"payload": {
|
|
9
|
+
"title": "Document a 7-day refresh token as the default app client setting",
|
|
10
|
+
"description": "Ryan (2026-10-31): one week (seven days) should be the default refresh-token lifetime for the product app clients that cli-login signs in with. Access and ID tokens stay at 60 minutes. cli-login creates no Cognito client, so the default is the documented app client setup in README.md and docs/design.md.\n\nDone when:\n- README.md has a section listing the app client settings: code flow, PKCE, no client secret, refresh 7 days, access and ID 60 minutes, token revocation on, and exact callbacks with a fixed loopback port.\n- The security-limits bullet names the 7-day window.\n- docs/design.md points to that section.",
|
|
11
|
+
"issue_type": "story",
|
|
12
|
+
"status": "open",
|
|
13
|
+
"priority": 2,
|
|
14
|
+
"assignee": null,
|
|
15
|
+
"parent": "cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4",
|
|
16
|
+
"labels": [],
|
|
17
|
+
"agent": {
|
|
18
|
+
"platform": "claude_code",
|
|
19
|
+
"model": "claude-opus-5-5",
|
|
20
|
+
"name": "Prospectus implementation"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
cli_login-0.4.1/project/events/2026-10-31T15-27-42.778Z__65bd906f-6801-4df1-ae7b-2112c3629b32.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"event_id": "65bd906f-6801-4df1-ae7b-2112c3629b32",
|
|
4
|
+
"issue_id": "cli-login-79ca92d5-4534-4bea-bfe1-aaeaa5037b49",
|
|
5
|
+
"event_type": "state_transition",
|
|
6
|
+
"occurred_at": "2026-10-31T15:27:42.778Z",
|
|
7
|
+
"actor_id": "home",
|
|
8
|
+
"payload": {
|
|
9
|
+
"from_status": "open",
|
|
10
|
+
"to_status": "closed"
|
|
11
|
+
}
|
|
12
|
+
}
|
cli_login-0.4.1/project/events/2026-10-31T15-27-43.836Z__5da8b7b7-4903-4767-a638-3cb0d95c32a0.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"event_id": "5da8b7b7-4903-4767-a638-3cb0d95c32a0",
|
|
4
|
+
"issue_id": "cli-login-79ca92d5-4534-4bea-bfe1-aaeaa5037b49",
|
|
5
|
+
"event_type": "state_transition",
|
|
6
|
+
"occurred_at": "2026-10-31T15:27:43.836Z",
|
|
7
|
+
"actor_id": "home",
|
|
8
|
+
"payload": {
|
|
9
|
+
"from_status": "open",
|
|
10
|
+
"to_status": "closed"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "cli-login-79ca92d5-4534-4bea-bfe1-aaeaa5037b49",
|
|
3
|
+
"title": "Document a 7-day refresh token as the default app client setting",
|
|
4
|
+
"description": "Ryan (2026-10-31): one week (seven days) should be the default refresh-token lifetime for the product app clients that cli-login signs in with. Access and ID tokens stay at 60 minutes. cli-login creates no Cognito client, so the default is the documented app client setup in README.md and docs/design.md.\n\nDone when:\n- README.md has a section listing the app client settings: code flow, PKCE, no client secret, refresh 7 days, access and ID 60 minutes, token revocation on, and exact callbacks with a fixed loopback port.\n- The security-limits bullet names the 7-day window.\n- docs/design.md points to that section.",
|
|
5
|
+
"type": "story",
|
|
6
|
+
"status": "closed",
|
|
7
|
+
"priority": 2,
|
|
8
|
+
"assignee": null,
|
|
9
|
+
"creator": null,
|
|
10
|
+
"parent": "cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4",
|
|
11
|
+
"labels": [],
|
|
12
|
+
"dependencies": [],
|
|
13
|
+
"comments": [],
|
|
14
|
+
"created_at": "2026-10-31T15:05:12.601696Z",
|
|
15
|
+
"updated_at": "2026-10-31T15:27:43.836828Z",
|
|
16
|
+
"closed_at": "2026-10-31T15:27:43.836802Z",
|
|
17
|
+
"agent": {
|
|
18
|
+
"platform": "claude_code",
|
|
19
|
+
"model": "claude-opus-5-5",
|
|
20
|
+
"name": "Prospectus implementation"
|
|
21
|
+
},
|
|
22
|
+
"right_now_summary": null,
|
|
23
|
+
"right_now_updated_at": null,
|
|
24
|
+
"custom": {
|
|
25
|
+
"project_label": "cli-login",
|
|
26
|
+
"source": "shared"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cli-login"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "Browser sign-in for CLIs without long-lived keys: PKCE, remote approval, secure storage and renewal."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -332,3 +332,28 @@ def test_a_malformed_product_nonce_or_enrollment_is_refused_and_nothing_is_bound
|
|
|
332
332
|
transaction = started(b)
|
|
333
333
|
assert b.bind({"transaction": transaction, "nonce_hash": sha256_hex("n"), **extra}) == {"ok": False, "error": "unavailable"}
|
|
334
334
|
assert b.describe({"transaction": transaction})["ok"] is True
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_a_second_bind_cannot_replace_the_nonce_or_enrollment_of_the_first():
|
|
338
|
+
b = broker()
|
|
339
|
+
transaction = started(b)
|
|
340
|
+
assert b.bind({"transaction": transaction, "nonce_hash": sha256_hex("n1"), "nonce": "challenge-nonce-0001",
|
|
341
|
+
"enrollment": "proof-1"}) == {"ok": True}
|
|
342
|
+
assert b.bind({"transaction": transaction, "nonce_hash": sha256_hex("n2"), "nonce": "challenge-nonce-0002",
|
|
343
|
+
"enrollment": "proof-2"}) == {"ok": False, "error": "unavailable"}
|
|
344
|
+
item = b.store.get(transaction)
|
|
345
|
+
assert item["oidc_nonce"] == "challenge-nonce-0001" and item["enrollment"] == "proof-1" and item["nonce_hash"] == sha256_hex("n1")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_a_code_completed_for_one_transaction_is_never_handed_to_another_transactions_poll():
|
|
349
|
+
clock = Clock()
|
|
350
|
+
b = broker(clock)
|
|
351
|
+
first, second = started(b), started(b)
|
|
352
|
+
b.bind({"transaction": first, "nonce_hash": sha256_hex("n1"), "nonce": "challenge-nonce-0001", "enrollment": "proof-1"})
|
|
353
|
+
b.bind({"transaction": second, "nonce_hash": sha256_hex("n2")})
|
|
354
|
+
assert b.complete({"transaction": first, "nonce": "n2", "code": "code-1"}) == {"ok": False, "error": "unavailable"}
|
|
355
|
+
assert b.complete({"transaction": first, "nonce": "n1", "code": "code-1"}) == {"ok": True}
|
|
356
|
+
clock.now += 5
|
|
357
|
+
assert b.poll({"transaction": second, "poll_secret": SECRET}, "ip")[1]["error"] == "authorization_pending"
|
|
358
|
+
status, answer = b.poll({"transaction": first, "poll_secret": SECRET}, "ip")
|
|
359
|
+
assert status == 200 and answer["code"] == "code-1" and answer["nonce"] == "challenge-nonce-0001"
|
|
@@ -172,3 +172,47 @@ def test_when_prepare_fails_nothing_is_bound_and_the_link_still_works():
|
|
|
172
172
|
def counter():
|
|
173
173
|
tokens = iter(f"tok{n:02d}" for n in range(99))
|
|
174
174
|
return lambda _n: next(tokens)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def post_approve(pages, transaction, csrf):
|
|
178
|
+
return pages.approve("POST", {}, {"t": transaction, "csrf": csrf, "action": "approve", "confirm": "yes"}, {CSRF_COOKIE: csrf})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_a_replayed_approval_cannot_change_the_nonce_the_first_approval_bound():
|
|
182
|
+
broker, _, transaction = world()
|
|
183
|
+
challenges = iter(["challenge-nonce-0001", "challenge-nonce-0002"])
|
|
184
|
+
pages = ApprovalPages(broker.handle, authorize_endpoint=AUTHORIZE, product="Example", privileges="Act as you.",
|
|
185
|
+
prepare=lambda t, described: (next(challenges), "sealed-proof"), token=counter())
|
|
186
|
+
csrf = cookie(pages.approve("GET", {"t": transaction}, {}, {}), CSRF_COOKIE)
|
|
187
|
+
first = post_approve(pages, transaction, csrf)
|
|
188
|
+
replay = post_approve(pages, transaction, csrf) # back button, a second tab or a double submit with the old cookie
|
|
189
|
+
assert first.status == 303 and replay.status == 410
|
|
190
|
+
assert cookie(replay, NONCE_COOKIE) is None and "Location" not in dict(replay.headers)
|
|
191
|
+
assert broker.store.get(transaction)["oidc_nonce"] == "challenge-nonce-0001"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_two_approvals_that_race_bind_one_nonce_and_only_that_one_reaches_the_provider():
|
|
195
|
+
import threading
|
|
196
|
+
|
|
197
|
+
broker, _, transaction = world()
|
|
198
|
+
meeting = threading.Barrier(2, timeout=5)
|
|
199
|
+
challenges, lock = iter(["challenge-nonce-0001", "challenge-nonce-0002"]), threading.Lock()
|
|
200
|
+
|
|
201
|
+
def prepare(t, described):
|
|
202
|
+
meeting.wait() # both requests have seen the transaction pending before either binds
|
|
203
|
+
with lock:
|
|
204
|
+
return next(challenges), "sealed-proof"
|
|
205
|
+
|
|
206
|
+
pages = ApprovalPages(broker.handle, authorize_endpoint=AUTHORIZE, product="Example", privileges="Act as you.",
|
|
207
|
+
prepare=prepare, token=lambda _n: "t" * 32)
|
|
208
|
+
csrf = cookie(pages.approve("GET", {"t": transaction}, {}, {}), CSRF_COOKIE)
|
|
209
|
+
answers = []
|
|
210
|
+
threads = [threading.Thread(target=lambda: answers.append(post_approve(pages, transaction, csrf))) for _ in range(2)]
|
|
211
|
+
for thread in threads:
|
|
212
|
+
thread.start()
|
|
213
|
+
for thread in threads:
|
|
214
|
+
thread.join()
|
|
215
|
+
assert sorted(page.status for page in answers) == [303, 410]
|
|
216
|
+
sent = next(page for page in answers if page.status == 303)
|
|
217
|
+
nonce = parse_qs(urlsplit(header(sent, "Location")).query)["nonce"][0]
|
|
218
|
+
assert broker.store.get(transaction)["oidc_nonce"] == nonce
|
|
@@ -30,9 +30,10 @@ from .errors import AuthError, OAuthError
|
|
|
30
30
|
class RemoteLoginError(OAuthError):
|
|
31
31
|
"""A remote sign-in that did not finish. ``transient`` says whether trying again later may work."""
|
|
32
32
|
|
|
33
|
-
def __init__(self, code: str, message: str, *, transient: bool = False):
|
|
33
|
+
def __init__(self, code: str, message: str, *, transient: bool = False, diagnostics: Mapping[str, str] | None = None):
|
|
34
34
|
self.code = code
|
|
35
35
|
self.transient = transient
|
|
36
|
+
self.diagnostics = dict(diagnostics or {})
|
|
36
37
|
AuthError.__init__(self, message)
|
|
37
38
|
|
|
38
39
|
|
|
@@ -55,6 +56,15 @@ def default_http(method: str, url: str, *, json_body=None, form=None, timeout: f
|
|
|
55
56
|
return status, body if isinstance(body, dict) else {}, {key.lower(): value for key, value in headers.items()}
|
|
56
57
|
|
|
57
58
|
|
|
59
|
+
def _fingerprint(value: object) -> str:
|
|
60
|
+
"""A short, non-reversible tag for comparing two values in a report; the value itself is never shown."""
|
|
61
|
+
if value is None:
|
|
62
|
+
return "none"
|
|
63
|
+
if not isinstance(value, str):
|
|
64
|
+
return "invalid"
|
|
65
|
+
return hashlib.sha256(value.encode()).hexdigest()[:8]
|
|
66
|
+
|
|
67
|
+
|
|
58
68
|
def _b64url(data: bytes) -> str:
|
|
59
69
|
return base64.urlsafe_b64encode(data).decode().rstrip("=")
|
|
60
70
|
|
|
@@ -103,6 +113,7 @@ def remote_login(
|
|
|
103
113
|
expected_email: str | None = None,
|
|
104
114
|
enroll: Callable[[Mapping[str, Any], str, str | None], Any] | None = None,
|
|
105
115
|
token=secrets.token_urlsafe,
|
|
116
|
+
login_command: str | None = None,
|
|
106
117
|
):
|
|
107
118
|
"""Sign in through the target's broker and return what ``store_token_set`` returns.
|
|
108
119
|
|
|
@@ -120,6 +131,11 @@ def remote_login(
|
|
|
120
131
|
enrollment of each login (the Cyclotron lab enrolls each Cognito login at Prospectus). Only then does the CLI
|
|
121
132
|
accept an OpenID nonce chosen by the product's approval page, which the broker hands over with the code;
|
|
122
133
|
without ``enroll`` the CLI checks its own nonce and nothing else.
|
|
134
|
+
|
|
135
|
+
``login_command`` is the command that starts this sign-in again; the nonce-mismatch error names it. That error
|
|
136
|
+
also carries ``diagnostics``: whether ``enroll`` was given, whether the broker handed over a nonce, and short
|
|
137
|
+
one-way tags of the transaction and of the nonces (never a token or a nonce), so a failure that does not repeat
|
|
138
|
+
can still be told apart from one that does.
|
|
123
139
|
"""
|
|
124
140
|
broker = getattr(target, "broker_endpoint", None)
|
|
125
141
|
if not broker:
|
|
@@ -207,7 +223,21 @@ def remote_login(
|
|
|
207
223
|
claims = _claims(tokens.get("id_token"))
|
|
208
224
|
expected_nonce = answer.get("nonce") if enroll is not None and isinstance(answer.get("nonce"), str) else nonce
|
|
209
225
|
if claims.get("nonce") != expected_nonce:
|
|
210
|
-
|
|
226
|
+
diagnostics = {
|
|
227
|
+
"enroll": "on" if enroll is not None else "off",
|
|
228
|
+
"broker_nonce": "none" if answer.get("nonce") is None else "handed",
|
|
229
|
+
"transaction": _fingerprint(transaction),
|
|
230
|
+
"own_nonce": _fingerprint(nonce),
|
|
231
|
+
"expected_nonce": _fingerprint(expected_nonce),
|
|
232
|
+
"received_nonce": _fingerprint(claims.get("nonce")),
|
|
233
|
+
}
|
|
234
|
+
again = f"Start the sign-in again with `{login_command}`." if login_command else "Start the sign-in again."
|
|
235
|
+
raise RemoteLoginError(
|
|
236
|
+
"nonce_mismatch",
|
|
237
|
+
f"The sign-in answered tokens for a different request; nothing was saved. {again} "
|
|
238
|
+
f"If it repeats, report these tags (one-way, no secrets): {' '.join(f'{k}={v}' for k, v in diagnostics.items())}.",
|
|
239
|
+
diagnostics=diagnostics,
|
|
240
|
+
)
|
|
211
241
|
email = claims.get("email") if isinstance(claims.get("email"), str) else None
|
|
212
242
|
if expected_email is not None and (email or "").casefold() != expected_email.casefold():
|
|
213
243
|
raise RemoteLoginError("unexpected_identity", "Someone other than the expected person approved this sign-in; nothing was saved.")
|
|
@@ -354,3 +354,57 @@ def test_a_refused_enrollment_stores_nothing():
|
|
|
354
354
|
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock,
|
|
355
355
|
sleep=world.sleep, host="cloud-box", enroll=enrolling(world, calls, fail=True))
|
|
356
356
|
assert raised.value.code == "enrollment_refused" and stored == []
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def test_a_nonce_mismatch_says_to_start_again_and_reports_one_way_tags_without_any_nonce():
|
|
360
|
+
world, stored = World(), []
|
|
361
|
+
original = world.http
|
|
362
|
+
|
|
363
|
+
def swapped(method, url, **kwargs):
|
|
364
|
+
status, body, headers = original(method, url, **kwargs)
|
|
365
|
+
if url == TOKEN and status == 200:
|
|
366
|
+
body["id_token"] = jwt({"nonce": "someone-elses-nonce"})
|
|
367
|
+
return status, body, headers
|
|
368
|
+
|
|
369
|
+
world.http = swapped
|
|
370
|
+
with pytest.raises(RemoteLoginError) as raised:
|
|
371
|
+
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock, sleep=world.sleep,
|
|
372
|
+
host="cloud-box", login_command="app login --remote example.test")
|
|
373
|
+
error = raised.value
|
|
374
|
+
assert error.code == "nonce_mismatch" and stored == []
|
|
375
|
+
assert "nothing was saved" in str(error) and "Start the sign-in again with `app login --remote example.test`." in str(error)
|
|
376
|
+
tag = hashlib.sha256(b"someone-elses-nonce").hexdigest()[:8]
|
|
377
|
+
assert error.diagnostics["received_nonce"] == tag and error.diagnostics["enroll"] == "off"
|
|
378
|
+
assert error.diagnostics["broker_nonce"] == "none" and error.diagnostics["expected_nonce"] == error.diagnostics["own_nonce"]
|
|
379
|
+
assert error.diagnostics["transaction"] == hashlib.sha256(world.transaction.encode()).hexdigest()[:8]
|
|
380
|
+
assert "someone-elses-nonce" not in str(error) and world.transaction not in str(error)
|
|
381
|
+
assert all(len(value) <= 8 for key, value in error.diagnostics.items() if key.endswith("nonce") or key == "transaction")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_the_diagnostics_tell_a_page_that_used_the_product_nonce_from_a_cli_that_did_not_enroll():
|
|
385
|
+
world, stored = World(product_nonce="challenge-nonce-0001"), []
|
|
386
|
+
with pytest.raises(RemoteLoginError) as raised:
|
|
387
|
+
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock,
|
|
388
|
+
sleep=world.sleep, host="cloud-box")
|
|
389
|
+
diagnostics = raised.value.diagnostics
|
|
390
|
+
assert diagnostics["enroll"] == "off" and diagnostics["broker_nonce"] == "handed"
|
|
391
|
+
assert diagnostics["received_nonce"] == hashlib.sha256(b"challenge-nonce-0001").hexdigest()[:8] != diagnostics["expected_nonce"]
|
|
392
|
+
assert stored == [] and "Start the sign-in again." in str(raised.value)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_an_enrolling_cli_that_gets_the_other_nonce_refuses_and_never_retries_with_it():
|
|
396
|
+
world, stored, calls = World(product_nonce="challenge-nonce-0001"), [], []
|
|
397
|
+
original = world.http
|
|
398
|
+
|
|
399
|
+
def swapped(method, url, **kwargs):
|
|
400
|
+
status, body, headers = original(method, url, **kwargs)
|
|
401
|
+
if url == TOKEN and status == 200:
|
|
402
|
+
body["id_token"] = jwt({"nonce": "own-nonce-not-the-products"})
|
|
403
|
+
return status, body, headers
|
|
404
|
+
|
|
405
|
+
world.http = swapped
|
|
406
|
+
with pytest.raises(RemoteLoginError) as raised:
|
|
407
|
+
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock,
|
|
408
|
+
sleep=world.sleep, host="cloud-box", enroll=enrolling(world, calls))
|
|
409
|
+
assert raised.value.code == "nonce_mismatch" and raised.value.diagnostics["enroll"] == "on"
|
|
410
|
+
assert raised.value.diagnostics["broker_nonce"] == "handed" and stored == [] and calls == [] and len(world.exchanges) == 1
|
|
@@ -28,6 +28,7 @@ import ctypes
|
|
|
28
28
|
import json
|
|
29
29
|
import os
|
|
30
30
|
import re
|
|
31
|
+
import shlex
|
|
31
32
|
import signal
|
|
32
33
|
import socket
|
|
33
34
|
import stat
|
|
@@ -60,13 +61,19 @@ def _require_unix() -> None:
|
|
|
60
61
|
|
|
61
62
|
|
|
62
63
|
def agent_command(namespace: str, *, state_dir: Path | str | None = None, socket_path: Path | str | None = None) -> str:
|
|
63
|
-
"""The command that starts this namespace's agent, for guidance in error messages.
|
|
64
|
-
|
|
64
|
+
"""The command that starts this namespace's agent, for guidance in error messages.
|
|
65
|
+
|
|
66
|
+
It names the interpreter that is running right now by its full path, because the library may be installed
|
|
67
|
+
only in a private environment (``pipx``, ``uv tool``) where a bare ``python`` cannot import it. Every part
|
|
68
|
+
is shell-quoted, so the line works when pasted.
|
|
69
|
+
"""
|
|
70
|
+
parts = [sys.executable or "python", "-m", "cli_login.stores.agent", "serve", "--namespace", str(namespace)]
|
|
65
71
|
if state_dir is not None:
|
|
66
|
-
|
|
72
|
+
parts += ["--state-dir", str(state_dir)]
|
|
67
73
|
if socket_path is not None:
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
parts += ["--socket", str(socket_path)]
|
|
75
|
+
parts.append("--background")
|
|
76
|
+
return " ".join(shlex.quote(part) for part in parts)
|
|
70
77
|
|
|
71
78
|
|
|
72
79
|
def socket_path_for(namespace: str, state_dir: Path | str | None = None, environ=None) -> Path:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import shlex
|
|
2
3
|
import shutil
|
|
3
4
|
import socket
|
|
4
5
|
import stat
|
|
@@ -203,6 +204,32 @@ def test_the_command_line_serves_in_the_background_reports_and_stops(state):
|
|
|
203
204
|
assert run("status").returncode == 1
|
|
204
205
|
|
|
205
206
|
|
|
207
|
+
def test_the_advice_names_the_running_interpreter_by_full_path():
|
|
208
|
+
words = shlex.split(agent.agent_command("ns"))
|
|
209
|
+
assert words[0] == sys.executable and os.path.isabs(words[0])
|
|
210
|
+
assert words[1:] == ["-m", "cli_login.stores.agent", "serve", "--namespace", "ns", "--background"]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_the_advice_quotes_paths_with_spaces(monkeypatch):
|
|
214
|
+
monkeypatch.setattr(sys, "executable", "/opt/my tools/venv/bin/python")
|
|
215
|
+
command = agent.agent_command("ns", state_dir="/tmp/a b", socket_path="/tmp/it's/agent.sock")
|
|
216
|
+
assert shlex.split(command) == [
|
|
217
|
+
"/opt/my tools/venv/bin/python", "-m", "cli_login.stores.agent", "serve", "--namespace", "ns",
|
|
218
|
+
"--state-dir", "/tmp/a b", "--socket", "/tmp/it's/agent.sock", "--background",
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@unix
|
|
223
|
+
def test_the_advice_works_when_pasted_into_a_shell(state):
|
|
224
|
+
command = agent.agent_command("ns", state_dir=state)
|
|
225
|
+
try:
|
|
226
|
+
started = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=60, check=False)
|
|
227
|
+
assert started.returncode == 0, started.stderr
|
|
228
|
+
assert agent.status("ns", state_dir=state) is not None
|
|
229
|
+
finally:
|
|
230
|
+
agent.stop("ns", state_dir=state)
|
|
231
|
+
|
|
232
|
+
|
|
206
233
|
def test_windows_is_not_supported(monkeypatch, tmp_path):
|
|
207
234
|
monkeypatch.setattr(sys, "platform", "win32")
|
|
208
235
|
with pytest.raises(AuthError, match="Windows"):
|
|
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
|
|
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
|