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.
Files changed (129) hide show
  1. {cli_login-0.2.0 → cli_login-0.3.0}/PKG-INFO +1 -1
  2. {cli_login-0.2.0 → cli_login-0.3.0}/docs/protocol.md +4 -2
  3. cli_login-0.3.0/project/events/2026-10-30T14-01-17.888Z__bf995622-7f63-4936-9891-4cebf7208cf5.json +12 -0
  4. cli_login-0.3.0/project/events/2026-10-30T14-01-17.900Z__e9675700-922f-449e-8b9f-9779917ce373.json +12 -0
  5. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-96ed2301-f038-4d76-84bb-b670184ab538.json +10 -3
  6. {cli_login-0.2.0 → cli_login-0.3.0}/pyproject.toml +1 -1
  7. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/__init__.py +1 -1
  8. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/handler.py +17 -2
  9. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/handler_test.py +31 -0
  10. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/pages.py +16 -2
  11. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/pages_test.py +39 -0
  12. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/remote.py +12 -1
  13. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/remote_test.py +43 -3
  14. {cli_login-0.2.0 → cli_login-0.3.0}/.claude/settings.json +0 -0
  15. {cli_login-0.2.0 → cli_login-0.3.0}/.cursorignore +0 -0
  16. {cli_login-0.2.0 → cli_login-0.3.0}/.github/workflows/release.yml +0 -0
  17. {cli_login-0.2.0 → cli_login-0.3.0}/.github/workflows/tests.yml +0 -0
  18. {cli_login-0.2.0 → cli_login-0.3.0}/.gitignore +0 -0
  19. {cli_login-0.2.0 → cli_login-0.3.0}/.kanbus.yml +0 -0
  20. {cli_login-0.2.0 → cli_login-0.3.0}/.vscode/settings.json +0 -0
  21. {cli_login-0.2.0 → cli_login-0.3.0}/AGENTS.md +0 -0
  22. {cli_login-0.2.0 → cli_login-0.3.0}/CONTRIBUTING_AGENT.md +0 -0
  23. {cli_login-0.2.0 → cli_login-0.3.0}/CONTRIBUTING_AGENT.template.md +0 -0
  24. {cli_login-0.2.0 → cli_login-0.3.0}/LICENSE +0 -0
  25. {cli_login-0.2.0 → cli_login-0.3.0}/README.md +0 -0
  26. {cli_login-0.2.0 → cli_login-0.3.0}/docs/design.md +0 -0
  27. {cli_login-0.2.0 → cli_login-0.3.0}/features/environment.py +0 -0
  28. {cli_login-0.2.0 → cli_login-0.3.0}/features/locks.feature +0 -0
  29. {cli_login-0.2.0 → cli_login-0.3.0}/features/login_session.feature +0 -0
  30. {cli_login-0.2.0 → cli_login-0.3.0}/features/oauth_provider.feature +0 -0
  31. {cli_login-0.2.0 → cli_login-0.3.0}/features/remote_sign_in.feature +0 -0
  32. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/locks_steps.py +0 -0
  33. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/login_session_steps.py +0 -0
  34. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/oauth_provider_steps.py +0 -0
  35. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/remote_sign_in_steps.py +0 -0
  36. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/store_common_steps.py +0 -0
  37. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_agent_steps.py +0 -0
  38. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_kernel_keyring_steps.py +0 -0
  39. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_macos_steps.py +0 -0
  40. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_secret_service_steps.py +0 -0
  41. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_selection_steps.py +0 -0
  42. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/stores_windows_steps.py +0 -0
  43. {cli_login-0.2.0 → cli_login-0.3.0}/features/steps/target_steps.py +0 -0
  44. {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_agent.feature +0 -0
  45. {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_agent_windows.feature +0 -0
  46. {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_kernel_keyring.feature +0 -0
  47. {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_macos.feature +0 -0
  48. {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_secret_service.feature +0 -0
  49. {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_selection.feature +0 -0
  50. {cli_login-0.2.0 → cli_login-0.3.0}/features/stores_windows.feature +0 -0
  51. {cli_login-0.2.0 → cli_login-0.3.0}/features/target.feature +0 -0
  52. {cli_login-0.2.0 → cli_login-0.3.0}/infra/broker.yaml +0 -0
  53. {cli_login-0.2.0 → cli_login-0.3.0}/infra/broker_template_test.py +0 -0
  54. {cli_login-0.2.0 → cli_login-0.3.0}/project/AGENTS.md +0 -0
  55. {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
  56. {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
  57. {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
  58. {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
  59. {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
  60. {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
  61. {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
  62. {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
  63. {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
  64. {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
  65. {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
  66. {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
  67. {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
  68. {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
  69. {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
  70. {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
  71. {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
  72. {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
  73. {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
  74. {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
  75. {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
  76. {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
  77. {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
  78. {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
  79. {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
  80. {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
  81. {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
  82. {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
  83. {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
  84. {cli_login-0.2.0 → cli_login-0.3.0}/project/events/AGENTS.md +0 -0
  85. {cli_login-0.2.0 → cli_login-0.3.0}/project/events/DO_NOT_EDIT +0 -0
  86. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/AGENTS.md +0 -0
  87. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/DO_NOT_EDIT +0 -0
  88. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-04de9a1b-0c3e-4589-b9b5-ae30cd3cdd18.json +0 -0
  89. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-1b4a091f-2bfc-405c-be6e-b787b4bb4696.json +0 -0
  90. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-31750ff1-8d69-4b03-b953-82da220c1a9f.json +0 -0
  91. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4.json +0 -0
  92. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-361d5c51-7951-4e46-9c57-a64cb6f8058f.json +0 -0
  93. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-85e67311-6ca7-4c57-9e44-4d414ebc618f.json +0 -0
  94. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-9a02d68d-d3ff-4dfd-a319-2c411f05fddd.json +0 -0
  95. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-cccc972a-7df2-4795-9f9b-d8805dfc142e.json +0 -0
  96. {cli_login-0.2.0 → cli_login-0.3.0}/project/issues/cli-login-ff76cbbc-d47b-49d5-91e1-4aa2ae15e99f.json +0 -0
  97. {cli_login-0.2.0 → cli_login-0.3.0}/project/wiki/index.md +0 -0
  98. {cli_login-0.2.0 → cli_login-0.3.0}/project/wiki/whats-next.md +0 -0
  99. {cli_login-0.2.0 → cli_login-0.3.0}/scripts/build_broker.py +0 -0
  100. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/__init___test.py +0 -0
  101. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/__init__.py +0 -0
  102. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/throttle.py +0 -0
  103. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/transactions.py +0 -0
  104. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/broker/transactions_test.py +0 -0
  105. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/concurrency_test.py +0 -0
  106. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/errors.py +0 -0
  107. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/errors_test.py +0 -0
  108. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/locks.py +0 -0
  109. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/locks_test.py +0 -0
  110. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/oauth.py +0 -0
  111. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/oauth_test.py +0 -0
  112. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/session.py +0 -0
  113. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/session_test.py +0 -0
  114. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/__init__.py +0 -0
  115. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/agent.py +0 -0
  116. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/agent_test.py +0 -0
  117. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/kernel_keyring.py +0 -0
  118. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/kernel_keyring_test.py +0 -0
  119. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/macos.py +0 -0
  120. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/macos_test.py +0 -0
  121. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/secret_service.py +0 -0
  122. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/secret_service_test.py +0 -0
  123. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/stores_test.py +0 -0
  124. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/windows.py +0 -0
  125. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/stores/windows_test.py +0 -0
  126. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/target.py +0 -0
  127. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/target_test.py +0 -0
  128. {cli_login-0.2.0 → cli_login-0.3.0}/src/cli_login/testing.py +0 -0
  129. {cli_login-0.2.0 → cli_login-0.3.0}/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.2.0
3
+ Version: 0.3.0
4
4
  Summary: One CLI login for every Anthus product: PKCE sign-in, remote approval, secure storage and renewal.
5
5
  Author: Anthus AI
6
6
  License: MIT
@@ -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
 
@@ -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
+ }
@@ -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": "in_progress",
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-30T11:58:11.250332Z",
36
- "closed_at": null,
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.2.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"
@@ -1,4 +1,4 @@
1
1
  """One CLI login for every Anthus product. See docs/design.md."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
4
4
  PROTOCOL_VERSION = 1
@@ -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
- return 200, {"protocol": PROTOCOL_VERSION, "code": taken["code"], "redirect_uri": self.config.callback_uri}
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
- bound = self.store.transition(event["transaction"], [PENDING], {"state": BOUND, "nonce_hash": nonce_hash})
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
- if not self.invoke({"operation": "bind", "transaction": transaction, "nonce_hash": hashlib.sha256(nonce.encode()).hexdigest()}).get("ok"):
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": described["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 claims.get("nonce") != nonce:
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
- self.broker.bind({"transaction": self.transaction, "nonce_hash": sha256_hex("browser")})
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