cli-login 0.2.0__tar.gz → 0.3.0__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.2.0 → cli_login-0.3.0}/PKG-INFO +1 -1
- {cli_login-0.2.0 → cli_login-0.3.0}/docs/protocol.md +4 -2
- cli_login-0.3.0/project/events/2026-10-30T14-01-17.888Z__bf995622-7f63-4936-9891-4cebf7208cf5.json +12 -0
- cli_login-0.3.0/project/events/2026-10-30T14-01-17.900Z__e9675700-922f-449e-8b9f-9779917ce373.json +12 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-96ed2301-f038-4d76-84bb-b670184ab538.json +10 -3
- {cli_login-0.2.0 → cli_login-0.3.0}/pyproject.toml +1 -1
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/__init__.py +1 -1
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/handler.py +17 -2
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/handler_test.py +31 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/pages.py +16 -2
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/pages_test.py +39 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/remote.py +12 -1
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/remote_test.py +43 -3
- {cli_login-0.2.0 → cli_login-0.3.0}/.claude/settings.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/.cursorignore +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/.github/workflows/release.yml +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/.github/workflows/tests.yml +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/.gitignore +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/.kanbus.yml +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/.vscode/settings.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/AGENTS.md +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/CONTRIBUTING_AGENT.md +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/CONTRIBUTING_AGENT.template.md +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/LICENSE +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/README.md +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/docs/design.md +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/environment.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/locks.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/login_session.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/oauth_provider.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/remote_sign_in.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/locks_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/login_session_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/oauth_provider_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/remote_sign_in_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/store_common_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_agent_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_kernel_keyring_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_macos_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_secret_service_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_selection_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_windows_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/target_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_agent.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_agent_windows.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_kernel_keyring.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_macos.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_secret_service.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_selection.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_windows.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/features/target.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/infra/broker.yaml +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/infra/broker_template_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/AGENTS.md +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.293Z__b9260b7c-7ee0-4df4-a3e0-618bf6410f7f.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.309Z__8329b8bd-f933-4597-b6ef-11d3b9e83819.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.319Z__02962f12-cc16-413f-94b1-9ac250cfe43a.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.329Z__78d07504-8941-45fc-aa87-a883260179b6.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.339Z__e2e9e373-9425-47cc-ab2e-97bd17a904b3.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.350Z__811313ad-5bef-4918-a09b-0c6fbee06c27.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.363Z__92385224-7ace-40cd-9f1f-09f3a491c086.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T10-53-42.529Z__b801b764-9abb-4714-9c9a-57f98ec6b120.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-43-05.049Z__5f67cee7-ed11-4602-b91a-cc0dc054a000.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-43-05.062Z__447020e8-b028-4fbe-9207-869e9aff8ee6.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-54-51.436Z__48a79b75-ead6-4a2d-a31c-bb9cedbaaabc.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-54-51.454Z__222fa29e-fada-4e25-a64d-e259c99cbb24.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-54-51.463Z__0f87a247-a94e-4611-a7a5-0a80f16b50a0.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-56-05.842Z__3faa8c4c-7c22-41ab-b86c-2e12e622eb6a.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-56-05.852Z__9c5e7de9-0269-47d0-9dab-e58850e21ac9.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.140Z__284dc920-8b9e-47e3-b924-4233c908a54e.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.154Z__08903f6c-aab5-4ea7-877c-cc67ed9cf832.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.163Z__9d412c33-67bf-4bd4-83af-0c55e972f94e.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.173Z__ed2149f3-4c1f-43e4-b38d-0f9b295a0234.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.183Z__8cc06e66-99f6-4529-b312-a70cbaff1ca9.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.193Z__3ee6c0c7-52ca-40c3-b7d2-5e0950b0dce2.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T11-58-11.250Z__57d95af6-0bf0-4cc6-b610-82f4c40fbff1.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T12-00-38.672Z__b391bfe3-c3b0-4a9d-8839-1381a6577b91.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T12-00-38.686Z__153c3c9d-7b02-43af-8d50-888e8a9d965d.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T12-07-40.329Z__ba26d852-684d-4347-bae5-d347cb69fddd.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T12-07-40.337Z__602e30d8-8ef7-40f3-9fe6-505e735f3b89.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T12-10-43.690Z__c2398383-7bf7-437e-8b4c-7aba9633cfbc.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T12-10-44.027Z__0644b779-33ed-49c7-91dc-166760f42aae.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/2026-10-30T12-28-43.772Z__f550a131-02c7-44c0-b76a-3694222d74a9.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/AGENTS.md +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/events/DO_NOT_EDIT +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/AGENTS.md +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/DO_NOT_EDIT +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-04de9a1b-0c3e-4589-b9b5-ae30cd3cdd18.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-1b4a091f-2bfc-405c-be6e-b787b4bb4696.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-31750ff1-8d69-4b03-b953-82da220c1a9f.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-361d5c51-7951-4e46-9c57-a64cb6f8058f.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-85e67311-6ca7-4c57-9e44-4d414ebc618f.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-9a02d68d-d3ff-4dfd-a319-2c411f05fddd.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-cccc972a-7df2-4795-9f9b-d8805dfc142e.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-ff76cbbc-d47b-49d5-91e1-4aa2ae15e99f.json +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/wiki/index.md +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/project/wiki/whats-next.md +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/scripts/build_broker.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/__init___test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/__init__.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/throttle.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/transactions.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/transactions_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/concurrency_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/errors.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/errors_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/locks.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/locks_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/oauth.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/oauth_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/session.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/session_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/__init__.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/agent.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/agent_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/kernel_keyring.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/kernel_keyring_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/macos.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/macos_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/secret_service.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/secret_service_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/stores_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/windows.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/windows_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/target.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/target_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/testing.py +0 -0
- {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/testing_processes.py +0 -0
|
@@ -95,7 +95,9 @@ The CLI checks:
|
|
|
95
95
|
- that `redirect_uri` equals its target's `remote_callback_uri`, and that the `approval_url` from `/start` is
|
|
96
96
|
on that URI's origin. A target with a broker must name its remote callback, even when its local sign-in uses a
|
|
97
97
|
loopback callback;
|
|
98
|
-
- that the ID token's `nonce` equals its own
|
|
98
|
+
- that the ID token's `nonce` equals its own. The one exception: a CLI that asked to enroll (`enroll=` in the
|
|
99
|
+
Python client) checks the nonce the poll handed over, if there is one. It then runs its enroll step, with its
|
|
100
|
+
tokens, the transaction and the `enrollment` string, before storing anything;
|
|
99
101
|
- that the approver's email is the expected one, when one is given.
|
|
100
102
|
|
|
101
103
|
It then passes the response to the session store, which checks issuer, audience, `token_use` and expiry.
|
|
@@ -117,7 +119,7 @@ A failure answers `{"ok": false, "error": "unavailable"}` and says nothing more.
|
|
|
117
119
|
| `operation` | Fields | Effect |
|
|
118
120
|
|---|---|---|
|
|
119
121
|
| `describe` | `transaction` | For a pending, unexpired transaction: `client_id`, `target`, `requester`, `matching_code`, `code_challenge`, `nonce`, `redirect_uri`, `expires_at` |
|
|
120
|
-
| `bind` | `transaction`, `nonce_hash` | Pending to bound, once. After this, nobody else can approve it. |
|
|
122
|
+
| `bind` | `transaction`, `nonce_hash`, optional `nonce` and `enrollment` | Pending to bound, once. After this, nobody else can approve it. A product whose sign-in enrolls each login (the Cyclotron lab, at Prospectus) sets the OpenID `nonce` its approval page uses and an opaque `enrollment` string. The poll hands both to the CLI with the code. |
|
|
121
123
|
| `complete` | `transaction`, `nonce`, `code` | Bound to approved, only when `sha256(nonce)` equals the bound hash |
|
|
122
124
|
| `deny` | `transaction` | Pending or bound to denied |
|
|
123
125
|
|
cli_login-0.3.0/project/events/2026-10-30T14-01-17.888Z__bf995622-7f63-4936-9891-4cebf7208cf5.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"event_id": "bf995622-7f63-4936-9891-4cebf7208cf5",
|
|
4
|
+
"issue_id": "cli-login-96ed2301-f038-4d76-84bb-b670184ab538",
|
|
5
|
+
"event_type": "comment_added",
|
|
6
|
+
"occurred_at": "2026-10-30T14:01:17.888Z",
|
|
7
|
+
"actor_id": "home",
|
|
8
|
+
"payload": {
|
|
9
|
+
"comment_id": "8e2eaf1f-f176-45b7-9f83-ebd9dbac93ee",
|
|
10
|
+
"comment_author": "home"
|
|
11
|
+
}
|
|
12
|
+
}
|
cli_login-0.3.0/project/events/2026-10-30T14-01-17.900Z__e9675700-922f-449e-8b9f-9779917ce373.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"event_id": "e9675700-922f-449e-8b9f-9779917ce373",
|
|
4
|
+
"issue_id": "cli-login-96ed2301-f038-4d76-84bb-b670184ab538",
|
|
5
|
+
"event_type": "state_transition",
|
|
6
|
+
"occurred_at": "2026-10-30T14:01:17.900Z",
|
|
7
|
+
"actor_id": "home",
|
|
8
|
+
"payload": {
|
|
9
|
+
"from_status": "in_progress",
|
|
10
|
+
"to_status": "closed"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"title": "Move Papyrus's CLI login into the library without breaking Mac sessions",
|
|
4
4
|
"description": "Owner: Papyrus session. Move src/papyrus_content/cli_user_auth.py and macos_keychain.py into anthus_cli_login (target, oauth, session, locks, stores/macos), behind a product target the caller supplies. Keep Keychain SERVICE 'papyrus-newsroom-oauth' and the per-site keys, so existing Mac sessions renew unchanged; keep Papyrus's behaviour and port its specs (features_backend/cli-user-auth.feature) as Gherkin here. Done when Papyrus's specs pass against the library with no behaviour change.",
|
|
5
5
|
"type": "story",
|
|
6
|
-
"status": "
|
|
6
|
+
"status": "closed",
|
|
7
7
|
"priority": 1,
|
|
8
8
|
"assignee": null,
|
|
9
9
|
"creator": null,
|
|
@@ -29,11 +29,18 @@
|
|
|
29
29
|
"text": "Library half done in #1 (merged). Kept in progress: 'without breaking Mac sessions' is only proven once the Papyrus CLI uses the library and an existing Keychain session renews through it. That Papyrus PR (Target per site, namespace papyrus-newsroom-oauth, identity_provider Google, existing state dir) is next after the stores story.",
|
|
30
30
|
"created_at": "2026-10-30T11:58:11.249739Z",
|
|
31
31
|
"comment_type": "default"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "8e2eaf1f-f176-45b7-9f83-ebd9dbac93ee",
|
|
35
|
+
"author": "home",
|
|
36
|
+
"text": "Done. Papyrus adopted cli-login==0.1.0 in AnthusAI/Papyrus#885 (merged to develop). Mac sessions: a scenario seeds an earlier-format Keychain session under papyrus-newsroom-oauth with the unchanged per-site key, and it renews through the library. Live: papyrus doctor on the M1 renewed Ryan's existing shipped.anth.us Keychain session through the library.",
|
|
37
|
+
"created_at": "2026-10-30T14:01:17.888119Z",
|
|
38
|
+
"comment_type": "default"
|
|
32
39
|
}
|
|
33
40
|
],
|
|
34
41
|
"created_at": "2026-10-30T10:52:21.309520Z",
|
|
35
|
-
"updated_at": "2026-10-
|
|
36
|
-
"closed_at":
|
|
42
|
+
"updated_at": "2026-10-30T14:01:17.900618Z",
|
|
43
|
+
"closed_at": "2026-10-30T14:01:17.900340Z",
|
|
37
44
|
"right_now_summary": null,
|
|
38
45
|
"right_now_updated_at": null,
|
|
39
46
|
"custom": {}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cli-login"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "One CLI login for every Anthus product: PKCE sign-in, remote approval, secure storage and renewal."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -184,7 +184,11 @@ class Broker:
|
|
|
184
184
|
taken = self.store.take(transaction)
|
|
185
185
|
if taken is None:
|
|
186
186
|
return 400, {"error": "expired_token", "protocol": PROTOCOL_VERSION}
|
|
187
|
-
|
|
187
|
+
answer = {"protocol": PROTOCOL_VERSION, "code": taken["code"], "redirect_uri": self.config.callback_uri}
|
|
188
|
+
for field, name in (("oidc_nonce", "nonce"), ("enrollment", "enrollment")):
|
|
189
|
+
if taken.get(field):
|
|
190
|
+
answer[name] = taken[field]
|
|
191
|
+
return 200, answer
|
|
188
192
|
if item["state"] == DENIED:
|
|
189
193
|
return 400, {"error": "access_denied", "protocol": PROTOCOL_VERSION}
|
|
190
194
|
self.store.transition(transaction, [PENDING, BOUND], {"last_poll_at": now}, expect={"last_poll_at": last})
|
|
@@ -218,10 +222,21 @@ class Broker:
|
|
|
218
222
|
}
|
|
219
223
|
|
|
220
224
|
def bind(self, event: dict[str, Any]) -> dict[str, Any]:
|
|
225
|
+
"""Pending to bound, once. A product may also set the OpenID nonce its sign-in uses (for example a central
|
|
226
|
+
identity challenge's nonce) and an opaque ``enrollment`` string, both handed to the CLI with the code."""
|
|
221
227
|
nonce_hash = event.get("nonce_hash")
|
|
222
228
|
if self._live(event.get("transaction"), [PENDING]) is None or not HEX64.fullmatch(str(nonce_hash or "")):
|
|
223
229
|
return {"ok": False, "error": "unavailable"}
|
|
224
|
-
|
|
230
|
+
changes = {"state": BOUND, "nonce_hash": nonce_hash}
|
|
231
|
+
if "nonce" in event:
|
|
232
|
+
if not NONCE.fullmatch(str(event["nonce"])):
|
|
233
|
+
return {"ok": False, "error": "unavailable"}
|
|
234
|
+
changes["oidc_nonce"] = event["nonce"]
|
|
235
|
+
if "enrollment" in event:
|
|
236
|
+
if not isinstance(event["enrollment"], str) or not event["enrollment"]:
|
|
237
|
+
return {"ok": False, "error": "unavailable"}
|
|
238
|
+
changes["enrollment"] = event["enrollment"]
|
|
239
|
+
bound = self.store.transition(event["transaction"], [PENDING], changes)
|
|
225
240
|
return {"ok": True} if bound else {"ok": False, "error": "unavailable"}
|
|
226
241
|
|
|
227
242
|
def complete(self, event: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import json
|
|
3
3
|
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
4
6
|
from cli_login.broker import throttle
|
|
5
7
|
from cli_login.broker.handler import Broker, BrokerConfig, matching_code, sha256_hex
|
|
6
8
|
from cli_login.broker.transactions import MemoryTransactions
|
|
@@ -301,3 +303,32 @@ def test_the_ceiling_resets_with_the_window():
|
|
|
301
303
|
assert b.start(start_body(), "caller-new")[0] == 429
|
|
302
304
|
clock.now += 60
|
|
303
305
|
assert b.start(start_body(), "caller-new")[0] == 200
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_a_product_nonce_and_enrollment_set_at_bind_come_back_once_with_the_code():
|
|
309
|
+
clock = Clock()
|
|
310
|
+
b = broker(clock)
|
|
311
|
+
transaction = started(b)
|
|
312
|
+
assert b.bind({"transaction": transaction, "nonce_hash": sha256_hex("n"), "nonce": "challenge-nonce-0001",
|
|
313
|
+
"enrollment": "sealed-proof"}) == {"ok": True}
|
|
314
|
+
assert b.complete({"transaction": transaction, "nonce": "n", "code": "code"}) == {"ok": True}
|
|
315
|
+
clock.now += 5
|
|
316
|
+
status, answer = b.poll({"transaction": transaction, "poll_secret": SECRET}, "ip")
|
|
317
|
+
assert status == 200 and answer["nonce"] == "challenge-nonce-0001" and answer["enrollment"] == "sealed-proof"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_without_a_product_nonce_the_poll_answer_is_unchanged():
|
|
321
|
+
clock = Clock()
|
|
322
|
+
b = broker(clock)
|
|
323
|
+
transaction = started(b)
|
|
324
|
+
approve(b, transaction)
|
|
325
|
+
clock.now += 5
|
|
326
|
+
assert set(b.poll({"transaction": transaction, "poll_secret": SECRET}, "ip")[1]) == {"protocol", "code", "redirect_uri"}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@pytest.mark.parametrize("extra", [{"nonce": "short"}, {"nonce": 5}, {"enrollment": ""}, {"enrollment": ["x"]}])
|
|
330
|
+
def test_a_malformed_product_nonce_or_enrollment_is_refused_and_nothing_is_bound(extra):
|
|
331
|
+
b = broker()
|
|
332
|
+
transaction = started(b)
|
|
333
|
+
assert b.bind({"transaction": transaction, "nonce_hash": sha256_hex("n"), **extra}) == {"ok": False, "error": "unavailable"}
|
|
334
|
+
assert b.describe({"transaction": transaction})["ok"] is True
|
|
@@ -89,12 +89,18 @@ class ApprovalPages:
|
|
|
89
89
|
privileges: str,
|
|
90
90
|
scope: str = "openid email profile",
|
|
91
91
|
identity_provider: str | None = None,
|
|
92
|
+
prepare: Callable[[str, Mapping], tuple[str, str]] | None = None,
|
|
92
93
|
token=secrets.token_urlsafe,
|
|
93
94
|
):
|
|
95
|
+
"""``prepare(transaction, described)``, when given, runs on Approve before the bind and returns the OpenID
|
|
96
|
+
nonce the sign-in must use and an opaque ``enrollment`` string for the product's own enrollment step (for
|
|
97
|
+
example a central identity challenge's nonce and its sealed proof). The broker hands both to the CLI with
|
|
98
|
+
the code; a CLI accepts the nonce only when it asked to enroll."""
|
|
94
99
|
self.invoke, self.authorize_endpoint, self.product = (lambda payload: invoke({**payload, "cli_login": PRIVATE_MARKER})), authorize_endpoint, product
|
|
95
100
|
parts = urlsplit(authorize_endpoint)
|
|
96
101
|
self.form_action = f"'self' {parts.scheme}://{parts.netloc}"
|
|
97
102
|
self.privileges, self.scope, self.identity_provider, self.token = privileges, scope, identity_provider, token
|
|
103
|
+
self.prepare = prepare
|
|
98
104
|
|
|
99
105
|
def approve(self, method: str, query: Mapping[str, str], form: Mapping[str, str], cookies: Mapping[str, str]) -> Page:
|
|
100
106
|
if method == "GET":
|
|
@@ -114,7 +120,15 @@ class ApprovalPages:
|
|
|
114
120
|
if not described.get("ok"):
|
|
115
121
|
return _gone()
|
|
116
122
|
nonce = self.token(32)
|
|
117
|
-
|
|
123
|
+
bind = {"operation": "bind", "transaction": transaction, "nonce_hash": hashlib.sha256(nonce.encode()).hexdigest()}
|
|
124
|
+
oidc_nonce = described["nonce"]
|
|
125
|
+
if self.prepare is not None:
|
|
126
|
+
try:
|
|
127
|
+
oidc_nonce, enrollment = self.prepare(transaction, described)
|
|
128
|
+
except Exception: # noqa: BLE001 - the product's enrollment service is unavailable: nothing is bound
|
|
129
|
+
return _message(503, "Sign-in is unavailable", "Try the approval link again in a moment.")
|
|
130
|
+
bind |= {"nonce": oidc_nonce, "enrollment": enrollment}
|
|
131
|
+
if not self.invoke(bind).get("ok"):
|
|
118
132
|
return _gone()
|
|
119
133
|
query_string = {
|
|
120
134
|
"client_id": described["client_id"],
|
|
@@ -122,7 +136,7 @@ class ApprovalPages:
|
|
|
122
136
|
"redirect_uri": described["redirect_uri"],
|
|
123
137
|
"scope": self.scope,
|
|
124
138
|
"state": f"{transaction}.{nonce}",
|
|
125
|
-
"nonce":
|
|
139
|
+
"nonce": oidc_nonce,
|
|
126
140
|
"code_challenge": described["code_challenge"],
|
|
127
141
|
"code_challenge_method": "S256",
|
|
128
142
|
}
|
|
@@ -133,3 +133,42 @@ def test_every_private_call_carries_the_marker():
|
|
|
133
133
|
privileges="Act as you.")
|
|
134
134
|
pages.approve("GET", {"t": "x"}, {}, {})
|
|
135
135
|
assert calls and all(call["cli_login"] == "private-v1" for call in calls)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_a_prepare_hook_sets_the_sign_in_nonce_and_the_enrollment():
|
|
139
|
+
broker, _, transaction = world()
|
|
140
|
+
seen = []
|
|
141
|
+
|
|
142
|
+
def prepare(t, described):
|
|
143
|
+
seen.append((t, described["client_id"]))
|
|
144
|
+
return "challenge-nonce-0001", "sealed-proof"
|
|
145
|
+
|
|
146
|
+
pages = ApprovalPages(broker.handle, authorize_endpoint=AUTHORIZE, product="Example", privileges="Act as you.",
|
|
147
|
+
prepare=prepare, token=counter())
|
|
148
|
+
page = pages.approve("GET", {"t": transaction}, {}, {})
|
|
149
|
+
csrf = cookie(page, CSRF_COOKIE)
|
|
150
|
+
approved = pages.approve("POST", {}, {"t": transaction, "csrf": csrf, "action": "approve", "confirm": "yes"}, {CSRF_COOKIE: csrf})
|
|
151
|
+
query = {key: value[0] for key, value in parse_qs(urlsplit(header(approved, "Location")).query).items()}
|
|
152
|
+
assert seen == [(transaction, "client-1")] and query["nonce"] == "challenge-nonce-0001"
|
|
153
|
+
item = broker.store.get(transaction)
|
|
154
|
+
assert item["oidc_nonce"] == "challenge-nonce-0001" and item["enrollment"] == "sealed-proof"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_when_prepare_fails_nothing_is_bound_and_the_link_still_works():
|
|
158
|
+
broker, _, transaction = world()
|
|
159
|
+
|
|
160
|
+
def prepare(t, described):
|
|
161
|
+
raise RuntimeError("central identity service unavailable")
|
|
162
|
+
|
|
163
|
+
pages = ApprovalPages(broker.handle, authorize_endpoint=AUTHORIZE, product="Example", privileges="Act as you.",
|
|
164
|
+
prepare=prepare, token=counter())
|
|
165
|
+
page = pages.approve("GET", {"t": transaction}, {}, {})
|
|
166
|
+
csrf = cookie(page, CSRF_COOKIE)
|
|
167
|
+
failed = pages.approve("POST", {}, {"t": transaction, "csrf": csrf, "action": "approve", "confirm": "yes"}, {CSRF_COOKIE: csrf})
|
|
168
|
+
assert failed.status == 503 and "central identity" not in failed.body
|
|
169
|
+
assert broker.describe({"transaction": transaction})["ok"] is True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def counter():
|
|
173
|
+
tokens = iter(f"tok{n:02d}" for n in range(99))
|
|
174
|
+
return lambda _n: next(tokens)
|
|
@@ -101,6 +101,7 @@ def remote_login(
|
|
|
101
101
|
host: str | None = None,
|
|
102
102
|
note: str | None = None,
|
|
103
103
|
expected_email: str | None = None,
|
|
104
|
+
enroll: Callable[[Mapping[str, Any], str, str | None], Any] | None = None,
|
|
104
105
|
token=secrets.token_urlsafe,
|
|
105
106
|
):
|
|
106
107
|
"""Sign in through the target's broker and return what ``store_token_set`` returns.
|
|
@@ -113,6 +114,12 @@ def remote_login(
|
|
|
113
114
|
Whoever approves first owns the session, and the approval URL and code are often relayed through chat. Pass
|
|
114
115
|
``expected_email`` to refuse, and store nothing, when the person who approved is someone else. The signed-in
|
|
115
116
|
identity is always printed.
|
|
117
|
+
|
|
118
|
+
``enroll(tokens, transaction, enrollment)``, when given, runs after the exchange and the identity checks and
|
|
119
|
+
before anything is stored; if it raises, nothing is stored. It is for a product whose sign-in needs its own
|
|
120
|
+
enrollment of each login (the Cyclotron lab enrolls each Cognito login at Prospectus). Only then does the CLI
|
|
121
|
+
accept an OpenID nonce chosen by the product's approval page, which the broker hands over with the code;
|
|
122
|
+
without ``enroll`` the CLI checks its own nonce and nothing else.
|
|
116
123
|
"""
|
|
117
124
|
broker = getattr(target, "broker_endpoint", None)
|
|
118
125
|
if not broker:
|
|
@@ -198,11 +205,15 @@ def remote_login(
|
|
|
198
205
|
if status != 200:
|
|
199
206
|
raise RemoteLoginError(str(tokens.get("error") or "exchange_refused"), "The sign-in provider refused the code; start the sign-in again.")
|
|
200
207
|
claims = _claims(tokens.get("id_token"))
|
|
201
|
-
if
|
|
208
|
+
expected_nonce = answer.get("nonce") if enroll is not None and isinstance(answer.get("nonce"), str) else nonce
|
|
209
|
+
if claims.get("nonce") != expected_nonce:
|
|
202
210
|
raise RemoteLoginError("nonce_mismatch", "The sign-in answered tokens for a different request; nothing was saved.")
|
|
203
211
|
email = claims.get("email") if isinstance(claims.get("email"), str) else None
|
|
204
212
|
if expected_email is not None and (email or "").casefold() != expected_email.casefold():
|
|
205
213
|
raise RemoteLoginError("unexpected_identity", "Someone other than the expected person approved this sign-in; nothing was saved.")
|
|
214
|
+
if enroll is not None:
|
|
215
|
+
enrollment = answer.get("enrollment") if isinstance(answer.get("enrollment"), str) else None
|
|
216
|
+
enroll(tokens, transaction, enrollment)
|
|
206
217
|
record = store_token_set(tokens)
|
|
207
218
|
out(f"Signed in as {email or claims.get('sub', 'an unknown identity')}.")
|
|
208
219
|
return record
|
|
@@ -32,7 +32,8 @@ def jwt(claims):
|
|
|
32
32
|
class World:
|
|
33
33
|
"""A real broker in-process, an approver who acts on the Nth poll, and a fake token endpoint."""
|
|
34
34
|
|
|
35
|
-
def __init__(self, *, approve_on_poll=2, deny=False):
|
|
35
|
+
def __init__(self, *, approve_on_poll=2, deny=False, product_nonce=None):
|
|
36
|
+
self.product_nonce = product_nonce
|
|
36
37
|
self.now = 1000.0
|
|
37
38
|
self.broker = Broker(
|
|
38
39
|
MemoryTransactions(),
|
|
@@ -51,12 +52,13 @@ class World:
|
|
|
51
52
|
|
|
52
53
|
def approver(self):
|
|
53
54
|
described = self.broker.describe({"transaction": self.transaction})
|
|
54
|
-
self.issued_nonce = described["nonce"]
|
|
55
|
+
self.issued_nonce = self.product_nonce or described["nonce"]
|
|
55
56
|
self.challenge = described["code_challenge"]
|
|
56
57
|
if self.deny:
|
|
57
58
|
self.broker.deny({"transaction": self.transaction})
|
|
58
59
|
return
|
|
59
|
-
|
|
60
|
+
extra = {"nonce": self.product_nonce, "enrollment": "sealed-proof"} if self.product_nonce else {}
|
|
61
|
+
self.broker.bind({"transaction": self.transaction, "nonce_hash": sha256_hex("browser"), **extra})
|
|
60
62
|
self.broker.complete({"transaction": self.transaction, "nonce": "browser", "code": "auth-code"})
|
|
61
63
|
|
|
62
64
|
def http(self, method, url, *, json_body=None, form=None, timeout=30):
|
|
@@ -314,3 +316,41 @@ def test_a_loopback_target_still_checks_the_brokers_callback_and_approval_origin
|
|
|
314
316
|
with pytest.raises(RemoteLoginError) as raised:
|
|
315
317
|
world.login(target=target)
|
|
316
318
|
assert raised.value.code == "invalid_broker_answer" and world.polls == 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def enrolling(world, calls, *, fail=False):
|
|
322
|
+
def enroll(tokens, transaction, enrollment):
|
|
323
|
+
calls.append((tokens["refresh_token"], transaction, enrollment))
|
|
324
|
+
if fail:
|
|
325
|
+
raise RemoteLoginError("enrollment_refused", "The product refused to enroll this sign-in.")
|
|
326
|
+
return enroll
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_an_enrolling_sign_in_accepts_the_product_nonce_and_enrolls_before_storing():
|
|
330
|
+
world, stored, calls = World(product_nonce="challenge-nonce-0001"), [], []
|
|
331
|
+
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock, sleep=world.sleep,
|
|
332
|
+
host="cloud-box", enroll=enrolling(world, calls))
|
|
333
|
+
assert calls == [("refresh", world.transaction, "sealed-proof")] and len(stored) == 1
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_without_enroll_a_product_nonce_is_never_accepted():
|
|
337
|
+
world, stored = World(product_nonce="challenge-nonce-0001"), []
|
|
338
|
+
with pytest.raises(RemoteLoginError) as raised:
|
|
339
|
+
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock,
|
|
340
|
+
sleep=world.sleep, host="cloud-box")
|
|
341
|
+
assert raised.value.code == "nonce_mismatch" and stored == []
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_an_enrolling_sign_in_without_a_product_nonce_keeps_its_own_nonce_check():
|
|
345
|
+
world, stored, calls = World(), [], []
|
|
346
|
+
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock, sleep=world.sleep,
|
|
347
|
+
host="cloud-box", enroll=enrolling(world, calls))
|
|
348
|
+
assert calls[0][2] is None and len(stored) == 1
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_a_refused_enrollment_stores_nothing():
|
|
352
|
+
world, stored, calls = World(product_nonce="challenge-nonce-0001"), [], []
|
|
353
|
+
with pytest.raises(RemoteLoginError) as raised:
|
|
354
|
+
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock,
|
|
355
|
+
sleep=world.sleep, host="cloud-box", enroll=enrolling(world, calls, fail=True))
|
|
356
|
+
assert raised.value.code == "enrollment_refused" and stored == []
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|