cli-login 0.2.0__tar.gz → 0.4.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 (132) hide show
  1. cli_login-0.4.0/PKG-INFO +318 -0
  2. cli_login-0.4.0/README.md +302 -0
  3. {cli_login-0.2.0 → cli_login-0.4.0}/docs/protocol.md +10 -2
  4. {cli_login-0.2.0 → cli_login-0.4.0}/features/login_session.feature +33 -0
  5. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/login_session_steps.py +95 -1
  6. cli_login-0.4.0/project/events/2026-10-30T14-01-17.888Z__bf995622-7f63-4936-9891-4cebf7208cf5.json +12 -0
  7. cli_login-0.4.0/project/events/2026-10-30T14-01-17.900Z__e9675700-922f-449e-8b9f-9779917ce373.json +12 -0
  8. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-96ed2301-f038-4d76-84bb-b670184ab538.json +10 -3
  9. {cli_login-0.2.0 → cli_login-0.4.0}/pyproject.toml +2 -2
  10. cli_login-0.4.0/src/cli_login/__init__.py +4 -0
  11. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/handler.py +17 -2
  12. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/handler_test.py +31 -0
  13. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/pages.py +16 -2
  14. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/pages_test.py +39 -0
  15. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/oauth.py +3 -0
  16. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/remote.py +12 -1
  17. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/remote_test.py +43 -3
  18. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/session.py +41 -4
  19. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/session_test.py +72 -0
  20. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/testing.py +5 -2
  21. cli_login-0.2.0/PKG-INFO +0 -104
  22. cli_login-0.2.0/README.md +0 -88
  23. cli_login-0.2.0/src/cli_login/__init__.py +0 -4
  24. {cli_login-0.2.0 → cli_login-0.4.0}/.claude/settings.json +0 -0
  25. {cli_login-0.2.0 → cli_login-0.4.0}/.cursorignore +0 -0
  26. {cli_login-0.2.0 → cli_login-0.4.0}/.github/workflows/release.yml +0 -0
  27. {cli_login-0.2.0 → cli_login-0.4.0}/.github/workflows/tests.yml +0 -0
  28. {cli_login-0.2.0 → cli_login-0.4.0}/.gitignore +0 -0
  29. {cli_login-0.2.0 → cli_login-0.4.0}/.kanbus.yml +0 -0
  30. {cli_login-0.2.0 → cli_login-0.4.0}/.vscode/settings.json +0 -0
  31. {cli_login-0.2.0 → cli_login-0.4.0}/AGENTS.md +0 -0
  32. {cli_login-0.2.0 → cli_login-0.4.0}/CONTRIBUTING_AGENT.md +0 -0
  33. {cli_login-0.2.0 → cli_login-0.4.0}/CONTRIBUTING_AGENT.template.md +0 -0
  34. {cli_login-0.2.0 → cli_login-0.4.0}/LICENSE +0 -0
  35. {cli_login-0.2.0 → cli_login-0.4.0}/docs/design.md +0 -0
  36. {cli_login-0.2.0 → cli_login-0.4.0}/features/environment.py +0 -0
  37. {cli_login-0.2.0 → cli_login-0.4.0}/features/locks.feature +0 -0
  38. {cli_login-0.2.0 → cli_login-0.4.0}/features/oauth_provider.feature +0 -0
  39. {cli_login-0.2.0 → cli_login-0.4.0}/features/remote_sign_in.feature +0 -0
  40. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/locks_steps.py +0 -0
  41. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/oauth_provider_steps.py +0 -0
  42. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/remote_sign_in_steps.py +0 -0
  43. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/store_common_steps.py +0 -0
  44. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_agent_steps.py +0 -0
  45. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_kernel_keyring_steps.py +0 -0
  46. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_macos_steps.py +0 -0
  47. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_secret_service_steps.py +0 -0
  48. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_selection_steps.py +0 -0
  49. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_windows_steps.py +0 -0
  50. {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/target_steps.py +0 -0
  51. {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_agent.feature +0 -0
  52. {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_agent_windows.feature +0 -0
  53. {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_kernel_keyring.feature +0 -0
  54. {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_macos.feature +0 -0
  55. {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_secret_service.feature +0 -0
  56. {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_selection.feature +0 -0
  57. {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_windows.feature +0 -0
  58. {cli_login-0.2.0 → cli_login-0.4.0}/features/target.feature +0 -0
  59. {cli_login-0.2.0 → cli_login-0.4.0}/infra/broker.yaml +0 -0
  60. {cli_login-0.2.0 → cli_login-0.4.0}/infra/broker_template_test.py +0 -0
  61. {cli_login-0.2.0 → cli_login-0.4.0}/project/AGENTS.md +0 -0
  62. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T10-52-21.293Z__b9260b7c-7ee0-4df4-a3e0-618bf6410f7f.json +0 -0
  63. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T10-52-21.309Z__8329b8bd-f933-4597-b6ef-11d3b9e83819.json +0 -0
  64. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T10-52-21.319Z__02962f12-cc16-413f-94b1-9ac250cfe43a.json +0 -0
  65. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T10-52-21.329Z__78d07504-8941-45fc-aa87-a883260179b6.json +0 -0
  66. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T10-52-21.339Z__e2e9e373-9425-47cc-ab2e-97bd17a904b3.json +0 -0
  67. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T10-52-21.350Z__811313ad-5bef-4918-a09b-0c6fbee06c27.json +0 -0
  68. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T10-52-21.363Z__92385224-7ace-40cd-9f1f-09f3a491c086.json +0 -0
  69. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T10-53-42.529Z__b801b764-9abb-4714-9c9a-57f98ec6b120.json +0 -0
  70. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-43-05.049Z__5f67cee7-ed11-4602-b91a-cc0dc054a000.json +0 -0
  71. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-43-05.062Z__447020e8-b028-4fbe-9207-869e9aff8ee6.json +0 -0
  72. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-54-51.436Z__48a79b75-ead6-4a2d-a31c-bb9cedbaaabc.json +0 -0
  73. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-54-51.454Z__222fa29e-fada-4e25-a64d-e259c99cbb24.json +0 -0
  74. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-54-51.463Z__0f87a247-a94e-4611-a7a5-0a80f16b50a0.json +0 -0
  75. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-56-05.842Z__3faa8c4c-7c22-41ab-b86c-2e12e622eb6a.json +0 -0
  76. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-56-05.852Z__9c5e7de9-0269-47d0-9dab-e58850e21ac9.json +0 -0
  77. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-57-13.140Z__284dc920-8b9e-47e3-b924-4233c908a54e.json +0 -0
  78. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-57-13.154Z__08903f6c-aab5-4ea7-877c-cc67ed9cf832.json +0 -0
  79. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-57-13.163Z__9d412c33-67bf-4bd4-83af-0c55e972f94e.json +0 -0
  80. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-57-13.173Z__ed2149f3-4c1f-43e4-b38d-0f9b295a0234.json +0 -0
  81. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-57-13.183Z__8cc06e66-99f6-4529-b312-a70cbaff1ca9.json +0 -0
  82. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-57-13.193Z__3ee6c0c7-52ca-40c3-b7d2-5e0950b0dce2.json +0 -0
  83. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T11-58-11.250Z__57d95af6-0bf0-4cc6-b610-82f4c40fbff1.json +0 -0
  84. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T12-00-38.672Z__b391bfe3-c3b0-4a9d-8839-1381a6577b91.json +0 -0
  85. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T12-00-38.686Z__153c3c9d-7b02-43af-8d50-888e8a9d965d.json +0 -0
  86. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T12-07-40.329Z__ba26d852-684d-4347-bae5-d347cb69fddd.json +0 -0
  87. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T12-07-40.337Z__602e30d8-8ef7-40f3-9fe6-505e735f3b89.json +0 -0
  88. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T12-10-43.690Z__c2398383-7bf7-437e-8b4c-7aba9633cfbc.json +0 -0
  89. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T12-10-44.027Z__0644b779-33ed-49c7-91dc-166760f42aae.json +0 -0
  90. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/2026-10-30T12-28-43.772Z__f550a131-02c7-44c0-b76a-3694222d74a9.json +0 -0
  91. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/AGENTS.md +0 -0
  92. {cli_login-0.2.0 → cli_login-0.4.0}/project/events/DO_NOT_EDIT +0 -0
  93. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/AGENTS.md +0 -0
  94. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/DO_NOT_EDIT +0 -0
  95. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-04de9a1b-0c3e-4589-b9b5-ae30cd3cdd18.json +0 -0
  96. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-1b4a091f-2bfc-405c-be6e-b787b4bb4696.json +0 -0
  97. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-31750ff1-8d69-4b03-b953-82da220c1a9f.json +0 -0
  98. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4.json +0 -0
  99. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-361d5c51-7951-4e46-9c57-a64cb6f8058f.json +0 -0
  100. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-85e67311-6ca7-4c57-9e44-4d414ebc618f.json +0 -0
  101. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-9a02d68d-d3ff-4dfd-a319-2c411f05fddd.json +0 -0
  102. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-cccc972a-7df2-4795-9f9b-d8805dfc142e.json +0 -0
  103. {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-ff76cbbc-d47b-49d5-91e1-4aa2ae15e99f.json +0 -0
  104. {cli_login-0.2.0 → cli_login-0.4.0}/project/wiki/index.md +0 -0
  105. {cli_login-0.2.0 → cli_login-0.4.0}/project/wiki/whats-next.md +0 -0
  106. {cli_login-0.2.0 → cli_login-0.4.0}/scripts/build_broker.py +0 -0
  107. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/__init___test.py +0 -0
  108. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/__init__.py +0 -0
  109. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/throttle.py +0 -0
  110. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/transactions.py +0 -0
  111. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/transactions_test.py +0 -0
  112. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/concurrency_test.py +0 -0
  113. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/errors.py +0 -0
  114. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/errors_test.py +0 -0
  115. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/locks.py +0 -0
  116. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/locks_test.py +0 -0
  117. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/oauth_test.py +0 -0
  118. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/__init__.py +0 -0
  119. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/agent.py +0 -0
  120. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/agent_test.py +0 -0
  121. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/kernel_keyring.py +0 -0
  122. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/kernel_keyring_test.py +0 -0
  123. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/macos.py +0 -0
  124. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/macos_test.py +0 -0
  125. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/secret_service.py +0 -0
  126. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/secret_service_test.py +0 -0
  127. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/stores_test.py +0 -0
  128. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/windows.py +0 -0
  129. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/windows_test.py +0 -0
  130. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/target.py +0 -0
  131. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/target_test.py +0 -0
  132. {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/testing_processes.py +0 -0
@@ -0,0 +1,318 @@
1
+ Metadata-Version: 2.5
2
+ Name: cli-login
3
+ Version: 0.4.0
4
+ Summary: Browser sign-in for CLIs without long-lived keys: PKCE, remote approval, secure storage and renewal.
5
+ Author: Anthus AI
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Provides-Extra: dev
10
+ Requires-Dist: behave>=1.2.6; extra == 'dev'
11
+ Requires-Dist: pytest>=8; extra == 'dev'
12
+ Requires-Dist: ruff>=0.6; extra == 'dev'
13
+ Provides-Extra: linux
14
+ Requires-Dist: secretstorage>=3.3; (sys_platform == 'linux') and extra == 'linux'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # cli-login
18
+
19
+ Browser sign-in for command-line tools without long-lived keys: PKCE, remote approval, secure storage and renewal.
20
+
21
+ ## Why
22
+
23
+ Attackers who use AI tools find and exploit leaked secrets faster than before, so a stored access key is a bigger
24
+ liability than it was. A system that needs long-lived keys has to keep them safe forever. cli-login lets a CLI avoid
25
+ them. The CLI signs the person in through their browser with the authorization-code flow and PKCE, and keeps only
26
+ short-lived tokens that renew, in the operating system's secret store. A machine with no browser, such as a cloud
27
+ box running an AI agent, prints a link and a short code instead. The person approves from another device, and a
28
+ small broker hands the waiting CLI its own session without ever holding a token.
29
+
30
+ ## Features
31
+
32
+ - Local browser sign-in with PKCE and a loopback callback.
33
+ - Remote sign-in for machines without a browser, approved from a phone or laptop.
34
+ - Secure storage only: macOS Keychain, Linux Secret Service, the Linux kernel keyring, a memory-only agent, or
35
+ Windows Credential Manager. There is no plaintext fallback.
36
+ - Automatic renewal with a per-key lock, so concurrent processes do not race.
37
+ - Logout that revokes the refresh token and says so when it could not.
38
+ - A deployable broker for remote sign-in: one AWS Lambda function and one DynamoDB table.
39
+ - A written, language-neutral [broker protocol](docs/protocol.md) with conformance specs.
40
+ - No runtime dependencies.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install cli-login
46
+ pip install "cli-login[linux]" # adds Secret Service support on Linux
47
+ ```
48
+
49
+ Requires Python 3.10 or newer.
50
+
51
+ ## Quick start
52
+
53
+ cli-login is a library, not a finished CLI. Your product describes its identity provider in a `Target`, creates a
54
+ `Session`, and mounts its own `login`, `logout` and `status` commands.
55
+
56
+ ```python
57
+ from cli_login.session import Session
58
+ from cli_login.target import Target
59
+
60
+ target = Target(
61
+ issuer="https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE",
62
+ client_id="example-app-client-id",
63
+ oauth_domain="https://auth.example.com",
64
+ token_endpoint="https://auth.example.com/oauth2/token",
65
+ api_endpoint="https://api.example.com/graphql",
66
+ key="production", # names this session in the store
67
+ namespace="mytool", # your product; also the state directory and store name
68
+ callback_uri="http://127.0.0.1:5182/oauth/callback", # registered on the app client
69
+ )
70
+ session = Session(target, login_command="mytool login")
71
+
72
+ session.login() # opens the browser; returns {subject, label, created_at}
73
+ headers = session.headers(target.api_endpoint) # renews if needed; {"Authorization": ..., ...}
74
+ print(session.status()) # {"signed_in": True, "label": ..., ...}
75
+ print(session.logout()) # {"local_session_removed": True, "remote_revocation": ...}
76
+ ```
77
+
78
+ `Session(..., api_token="access", authorization_scheme="Bearer")` sends the access token as a bearer token instead of
79
+ the ID token. `headers()` refuses an endpoint other than `target.api_endpoint`.
80
+
81
+ ### Remote sign-in
82
+
83
+ Add the broker's endpoint and the HTTPS callback page your product serves, then pass the session's
84
+ `store_token_set` to `remote_login`:
85
+
86
+ ```python
87
+ from cli_login.remote import RemoteLoginError, remote_login
88
+
89
+ remote_target = Target(
90
+ # ... the fields above, plus:
91
+ broker_endpoint="https://broker.example.com",
92
+ remote_callback_uri="https://app.example.com/cli/callback",
93
+ )
94
+ session = Session(remote_target, login_command="mytool login --remote")
95
+
96
+ try:
97
+ remote_login(
98
+ remote_target,
99
+ session.store_token_set,
100
+ note="CI agent for the build box", # shown to the approver
101
+ expected_email="person@example.com", # refuse, and store nothing, if someone else approves
102
+ )
103
+ except RemoteLoginError as error:
104
+ print(error)
105
+ ```
106
+
107
+ It prints the approval URL and a short matching code, polls until the person approves, and stores the session.
108
+
109
+ ### Enrolling each login
110
+
111
+ A product whose sign-in must enroll each login somewhere else (the Cyclotron lab enrolls each Cognito login at
112
+ Prospectus) passes `enroll` to `remote_login`, and `prepare` and `enroll` to the local `Session.login`. Both hooks
113
+ are optional, and sign-in is unchanged without them.
114
+
115
+ ```python
116
+ def prepare(state):
117
+ # `state` is the raw state. Hash it however the product needs.
118
+ nonce, enrollment = my_product.begin_enrollment(state)
119
+ return nonce, enrollment # `enrollment` is opaque to cli-login
120
+
121
+ def enroll(tokens, state, enrollment): # remote_login passes the transaction where this has the state
122
+ my_product.finish_enrollment(tokens, enrollment) # raise to refuse the login
123
+
124
+ session.login(prepare=prepare, enroll=enroll)
125
+ ```
126
+
127
+ `prepare(state)` runs after the state and PKCE exist and before the authorize URL is built. The nonce it returns is
128
+ sent as the OpenID `nonce`, and the ID token must carry it back, or the login is refused with `nonce_mismatch`
129
+ (`remote_login` applies the same rule). `enroll(tokens, state, enrollment)` runs after the exchange and the nonce
130
+ check and before anything is stored. If it raises, nothing is stored, the tokens are discarded and the refresh
131
+ token is revoked on a best-effort basis. The library never reads the contents of `enrollment`.
132
+
133
+ ## How it works
134
+
135
+ ### Local browser sign-in
136
+
137
+ `Session.login()` makes a random `state` and a PKCE verifier, then opens the provider's authorization URL with the
138
+ S256 challenge (and the product's `nonce`, if a `prepare` hook supplied one). It listens on the target's loopback callback (a fixed port on `127.0.0.1` or `localhost`) for the
139
+ code, checks `state`, and exchanges the code and verifier at the token endpoint. Before saving anything it checks
140
+ that the ID token's issuer, audience, token type, subject and expiry match the target.
141
+
142
+ ### Storage
143
+
144
+ Only the refresh token and a little metadata (subject, label, creation time) are stored, and only in a secure store;
145
+ see [Where sessions are stored](#where-sessions-are-stored). ID and access tokens stay in memory.
146
+
147
+ ### Renewal and locking
148
+
149
+ `headers()` returns the current token and renews it with the refresh token when it is within a minute of expiry.
150
+ Each stored key has its own lock file in a private (0700) state directory, taken with `flock` on POSIX and
151
+ `msvcrt.locking` on Windows, so concurrent processes renew one at a time and a logout in one process wins over a
152
+ cached token in another. If the provider answers `invalid_grant`, the stored session is deleted and the person is
153
+ told to sign in again. `logout()` revokes the refresh token at the provider, deletes the local session either way,
154
+ and reports `confirmed`, `unconfirmed` or `not_needed`. An access token the API already accepted stays valid until
155
+ it expires.
156
+
157
+ ### Remote sign-in with a split secret
158
+
159
+ The CLI keeps two secrets that never leave the machine: the PKCE verifier and a poll secret. The broker only ever
160
+ sees their hashes. The approver's browser keeps a third, a nonce in a cookie. The broker receives the
161
+ authorization code only after the approval page proves it holds that nonce, and gives it once, to the one poll that
162
+ knows the poll secret. The code is useless without the verifier.
163
+
164
+ ```mermaid
165
+ sequenceDiagram
166
+ participant CLI as CLI on the headless machine
167
+ participant Broker as Broker
168
+ participant Person as Approver's browser
169
+ participant IdP as Sign-in provider
170
+
171
+ CLI->>Broker: Start: send hashes of the verifier and poll secret, and a requester label
172
+ Broker-->>CLI: Answer with a transaction id, a matching code and the approval URL
173
+ CLI->>CLI: Print the approval URL and the matching code
174
+ CLI->>Broker: Poll: ask whether the transaction is approved, sending the poll secret
175
+ Broker-->>CLI: Answer "pending"
176
+ Person->>Broker: Open the approval page, which asks the broker to describe the transaction
177
+ Broker-->>Person: Show the requester label and the matching code
178
+ Person->>Person: Check that the code matches, then press Approve
179
+ Person->>Broker: Bind the transaction to a hash of the browser's nonce
180
+ Person->>IdP: Sign in at the provider and return with an authorization code
181
+ Person->>Broker: Callback page completes the transaction with the nonce and the code
182
+ CLI->>Broker: Poll again, sending the poll secret
183
+ Broker-->>CLI: Hand over the authorization code, once
184
+ CLI->>IdP: Exchange the code with the verifier for tokens
185
+ IdP-->>CLI: Answer with the tokens, which the CLI stores
186
+ ```
187
+
188
+ The full contract, including the private operations and the page requirements, is in
189
+ [docs/protocol.md](docs/protocol.md). A client in another language can implement the CLI side from it and run
190
+ `features/remote_sign_in.feature`.
191
+
192
+ ## Deploying the broker
193
+
194
+ Each product deployment runs one broker, built from `infra/broker.yaml`.
195
+
196
+ 1. Build the Lambda zip. The build is deterministic, so the same source gives the same hash:
197
+
198
+ ```bash
199
+ python scripts/build_broker.py broker.zip
200
+ ```
201
+
202
+ It prints the SHA-256 and the `CodeKey` to use (`cli-login/broker-<sha256>.zip`). Upload the zip to your bucket
203
+ under that key.
204
+ 2. Deploy the template with these parameters:
205
+
206
+ | Parameter | Meaning |
207
+ | --- | --- |
208
+ | `CodeBucket`, `CodeKey` | Where the zip is; a new key deploys new code |
209
+ | `ClientId` | Your Cognito app client id; the broker serves only this client |
210
+ | `Targets` | JSON list of the API endpoints a CLI may sign in to |
211
+ | `ApprovalUrl` | Your approval page, an `https://` URL |
212
+ | `CallbackUri` | Your callback page, registered on the app client, an `https://` URL |
213
+ | `InvokerRoleArn` | The backend role that serves both pages and may invoke the broker |
214
+ | `ReservedConcurrency` | Most invocations at once (default 10) |
215
+ | `LogRetentionDays` | Log retention (default 30) |
216
+ | `AlarmTopicArn` | Optional SNS topic told when the broker is throttled |
217
+
218
+ The `BrokerEndpoint` output is the `broker_endpoint` in your `Target`.
219
+ 3. Serve the two pages from your product's own domain. `cli_login.broker.pages.ApprovalPages` gives you
220
+ framework-neutral handlers for `approve` and `callback`; you give it a function that invokes the broker.
221
+
222
+ The broker and the approval pages must run the same cli-login version. Products should pin an exact version, since
223
+ releases before 1.0 may change behaviour.
224
+
225
+ ## Where sessions are stored
226
+
227
+ Sessions live only in a secure store, tried in this order. There is no plaintext fallback: if nothing is
228
+ usable, the tool fails with the exact steps to fix it.
229
+
230
+ | Platform | Store (in order) | Lasts |
231
+ | --- | --- | --- |
232
+ | macOS | Keychain (service name = the product namespace, so existing sessions keep working) | until logout |
233
+ | Linux | 1. Secret Service (D-Bus), needs `pip install "cli-login[linux]"` and an unlocked keyring; 2. kernel keyring; 3. the memory agent | keyring: until logout; kernel keyring: until reboot, or the persistent keyring's expiry (3 days from last use by default); agent: until it stops |
234
+ | Windows | Credential Manager (`<namespace>/<key>`; values over 2,560 bytes are chunked) | until logout |
235
+
236
+ The memory agent is not available on Windows in this version. Set `CLI_LOGIN_STORE` to `keychain`,
237
+ `secret-service`, `kernel-keyring`, `agent` or `credential-manager` to choose a store explicitly (for operators
238
+ and tests). Any other value is refused.
239
+
240
+ ### Cloud machines and containers
241
+
242
+ 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:
244
+
245
+ ```bash
246
+ python -m cli_login.stores.agent serve --namespace <product-namespace> --background
247
+ python -m cli_login.stores.agent status --namespace <product-namespace>
248
+ python -m cli_login.stores.agent stop --namespace <product-namespace>
249
+ ```
250
+
251
+ A product mounts the same functions (`stores.agent.serve`, `start_background`, `status`, `stop`, `main`) under
252
+ its own CLI name, such as `<product> auth agent`. The agent holds sessions in memory only, behind a 0600 Unix
253
+ socket in a 0700 directory under the product's state directory (`~/.config/cli-login/<namespace>/agent`,
254
+ or the socket named by `CLI_LOGIN_AGENT_SOCKET`, which must sit in a private directory and be short
255
+ enough for a Unix socket). It checks the caller's user id on every connection, disables core dumps, and keeps
256
+ nothing on disk. After it stops, or the machine restarts, sign in again. Processes of the same user can use it,
257
+ as with ssh-agent.
258
+
259
+ ### Recovering from each failure
260
+
261
+ - **"No Secret Service is reachable"**: no D-Bus session or keyring daemon. On a server, use the memory agent.
262
+ - **"Secret Service keyring is locked"**: unlock it by signing in to the desktop session, then retry.
263
+ - **"cli-login[linux]"**: the optional dependency is missing; `pip install "cli-login[linux]"`.
264
+ - **"kernel keyring is unavailable"**: the kernel or container blocks `add_key`/`keyctl`. Use the memory agent,
265
+ or run the container with a seccomp profile that allows them.
266
+ - **"The memory agent is not running"**: run the `serve` command shown in the message, then sign in again.
267
+ - **Agent socket refused**: the directory or socket is not owned by you or not private; fix the mode (0700) or
268
+ remove the stale path and restart the agent.
269
+ - **"The stored session is damaged"** (Windows): run the login command again to replace it.
270
+ - **Credential Manager errors on Windows**: sign in to Windows normally (not a restricted service account) and
271
+ retry.
272
+
273
+ ## Security model and limits
274
+
275
+ - **Whoever approves first owns the session.** The approval URL and code are often relayed through chat. The
276
+ requester label, the matching code the approver must confirm, and `expected_email` narrow the risk. They do not
277
+ remove it, and the CLI always prints the identity it signed in as.
278
+ - **Tokens can leak.** They are short-lived and renewable, and logout revokes the refresh token. A stolen refresh
279
+ token works until it expires or is revoked.
280
+ - **The broker never holds a token.** It holds an authorization code until the right CLI collects it, and the
281
+ code is useless without the verifier.
282
+ - **Cognito only, for now.** The library targets Amazon Cognito user pools with the authorization-code flow and PKCE.
283
+ - **The broker is AWS-only** (Lambda and DynamoDB), and **the client is Python-only**. The protocol is written down
284
+ so other clients can follow it.
285
+ - **Not yet tested:** a real renewal run of 24 hours or more; the approval flow in real browsers (Chrome, Safari,
286
+ Firefox); and the macOS Keychain store beyond fakes. The Linux Secret Service and kernel keyring run against
287
+ real backends in CI.
288
+ - Same-user processes can use the memory agent, as with ssh-agent.
289
+
290
+ ## Origins
291
+
292
+ cli-login was originally developed for the Anthus products (Papyrus and Cyclotron) and is now maintained under
293
+ DataParade-io.
294
+
295
+ ## Development
296
+
297
+ ```bash
298
+ python -m pip install -e ".[dev]" # add ",linux" on Linux for Secret Service tests
299
+ python -m ruff check .
300
+ python -m pytest -q -rs
301
+ python -m behave features
302
+ ```
303
+
304
+ CI runs these on Ubuntu, macOS and Windows with Python 3.10 and 3.13. On Linux it also runs against a real Secret
305
+ Service and kernel keyring.
306
+
307
+ ## Releasing (maintainers)
308
+
309
+ Pushing to `main` (a pull request from `develop`) runs python-semantic-release on the conventional commits:
310
+ `feat:` and `fix:` (or a breaking change) bump the version in `pyproject.toml`, tag `vX.Y.Z` and create a GitHub
311
+ release; other commits release nothing. The `pypi` job then builds the sdist and wheel and publishes them with
312
+ OIDC trusted publishing, so no PyPI token is stored anywhere. Publishing needs a PyPI trusted publisher, registered
313
+ by the PyPI account owner: project `cli-login`, owner `DataParade-io`, repository `cli-login`, workflow
314
+ `release.yml`, environment `pypi`. The workflow's `pypi` environment must also exist in the repository settings.
315
+
316
+ ## License
317
+
318
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,302 @@
1
+ # cli-login
2
+
3
+ Browser sign-in for command-line tools without long-lived keys: PKCE, remote approval, secure storage and renewal.
4
+
5
+ ## Why
6
+
7
+ Attackers who use AI tools find and exploit leaked secrets faster than before, so a stored access key is a bigger
8
+ liability than it was. A system that needs long-lived keys has to keep them safe forever. cli-login lets a CLI avoid
9
+ them. The CLI signs the person in through their browser with the authorization-code flow and PKCE, and keeps only
10
+ short-lived tokens that renew, in the operating system's secret store. A machine with no browser, such as a cloud
11
+ box running an AI agent, prints a link and a short code instead. The person approves from another device, and a
12
+ small broker hands the waiting CLI its own session without ever holding a token.
13
+
14
+ ## Features
15
+
16
+ - Local browser sign-in with PKCE and a loopback callback.
17
+ - Remote sign-in for machines without a browser, approved from a phone or laptop.
18
+ - Secure storage only: macOS Keychain, Linux Secret Service, the Linux kernel keyring, a memory-only agent, or
19
+ Windows Credential Manager. There is no plaintext fallback.
20
+ - Automatic renewal with a per-key lock, so concurrent processes do not race.
21
+ - Logout that revokes the refresh token and says so when it could not.
22
+ - A deployable broker for remote sign-in: one AWS Lambda function and one DynamoDB table.
23
+ - A written, language-neutral [broker protocol](docs/protocol.md) with conformance specs.
24
+ - No runtime dependencies.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install cli-login
30
+ pip install "cli-login[linux]" # adds Secret Service support on Linux
31
+ ```
32
+
33
+ Requires Python 3.10 or newer.
34
+
35
+ ## Quick start
36
+
37
+ cli-login is a library, not a finished CLI. Your product describes its identity provider in a `Target`, creates a
38
+ `Session`, and mounts its own `login`, `logout` and `status` commands.
39
+
40
+ ```python
41
+ from cli_login.session import Session
42
+ from cli_login.target import Target
43
+
44
+ target = Target(
45
+ issuer="https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE",
46
+ client_id="example-app-client-id",
47
+ oauth_domain="https://auth.example.com",
48
+ token_endpoint="https://auth.example.com/oauth2/token",
49
+ api_endpoint="https://api.example.com/graphql",
50
+ key="production", # names this session in the store
51
+ namespace="mytool", # your product; also the state directory and store name
52
+ callback_uri="http://127.0.0.1:5182/oauth/callback", # registered on the app client
53
+ )
54
+ session = Session(target, login_command="mytool login")
55
+
56
+ session.login() # opens the browser; returns {subject, label, created_at}
57
+ headers = session.headers(target.api_endpoint) # renews if needed; {"Authorization": ..., ...}
58
+ print(session.status()) # {"signed_in": True, "label": ..., ...}
59
+ print(session.logout()) # {"local_session_removed": True, "remote_revocation": ...}
60
+ ```
61
+
62
+ `Session(..., api_token="access", authorization_scheme="Bearer")` sends the access token as a bearer token instead of
63
+ the ID token. `headers()` refuses an endpoint other than `target.api_endpoint`.
64
+
65
+ ### Remote sign-in
66
+
67
+ Add the broker's endpoint and the HTTPS callback page your product serves, then pass the session's
68
+ `store_token_set` to `remote_login`:
69
+
70
+ ```python
71
+ from cli_login.remote import RemoteLoginError, remote_login
72
+
73
+ remote_target = Target(
74
+ # ... the fields above, plus:
75
+ broker_endpoint="https://broker.example.com",
76
+ remote_callback_uri="https://app.example.com/cli/callback",
77
+ )
78
+ session = Session(remote_target, login_command="mytool login --remote")
79
+
80
+ try:
81
+ remote_login(
82
+ remote_target,
83
+ session.store_token_set,
84
+ note="CI agent for the build box", # shown to the approver
85
+ expected_email="person@example.com", # refuse, and store nothing, if someone else approves
86
+ )
87
+ except RemoteLoginError as error:
88
+ print(error)
89
+ ```
90
+
91
+ It prints the approval URL and a short matching code, polls until the person approves, and stores the session.
92
+
93
+ ### Enrolling each login
94
+
95
+ A product whose sign-in must enroll each login somewhere else (the Cyclotron lab enrolls each Cognito login at
96
+ Prospectus) passes `enroll` to `remote_login`, and `prepare` and `enroll` to the local `Session.login`. Both hooks
97
+ are optional, and sign-in is unchanged without them.
98
+
99
+ ```python
100
+ def prepare(state):
101
+ # `state` is the raw state. Hash it however the product needs.
102
+ nonce, enrollment = my_product.begin_enrollment(state)
103
+ return nonce, enrollment # `enrollment` is opaque to cli-login
104
+
105
+ def enroll(tokens, state, enrollment): # remote_login passes the transaction where this has the state
106
+ my_product.finish_enrollment(tokens, enrollment) # raise to refuse the login
107
+
108
+ session.login(prepare=prepare, enroll=enroll)
109
+ ```
110
+
111
+ `prepare(state)` runs after the state and PKCE exist and before the authorize URL is built. The nonce it returns is
112
+ sent as the OpenID `nonce`, and the ID token must carry it back, or the login is refused with `nonce_mismatch`
113
+ (`remote_login` applies the same rule). `enroll(tokens, state, enrollment)` runs after the exchange and the nonce
114
+ check and before anything is stored. If it raises, nothing is stored, the tokens are discarded and the refresh
115
+ token is revoked on a best-effort basis. The library never reads the contents of `enrollment`.
116
+
117
+ ## How it works
118
+
119
+ ### Local browser sign-in
120
+
121
+ `Session.login()` makes a random `state` and a PKCE verifier, then opens the provider's authorization URL with the
122
+ S256 challenge (and the product's `nonce`, if a `prepare` hook supplied one). It listens on the target's loopback callback (a fixed port on `127.0.0.1` or `localhost`) for the
123
+ code, checks `state`, and exchanges the code and verifier at the token endpoint. Before saving anything it checks
124
+ that the ID token's issuer, audience, token type, subject and expiry match the target.
125
+
126
+ ### Storage
127
+
128
+ Only the refresh token and a little metadata (subject, label, creation time) are stored, and only in a secure store;
129
+ see [Where sessions are stored](#where-sessions-are-stored). ID and access tokens stay in memory.
130
+
131
+ ### Renewal and locking
132
+
133
+ `headers()` returns the current token and renews it with the refresh token when it is within a minute of expiry.
134
+ Each stored key has its own lock file in a private (0700) state directory, taken with `flock` on POSIX and
135
+ `msvcrt.locking` on Windows, so concurrent processes renew one at a time and a logout in one process wins over a
136
+ cached token in another. If the provider answers `invalid_grant`, the stored session is deleted and the person is
137
+ told to sign in again. `logout()` revokes the refresh token at the provider, deletes the local session either way,
138
+ and reports `confirmed`, `unconfirmed` or `not_needed`. An access token the API already accepted stays valid until
139
+ it expires.
140
+
141
+ ### Remote sign-in with a split secret
142
+
143
+ The CLI keeps two secrets that never leave the machine: the PKCE verifier and a poll secret. The broker only ever
144
+ sees their hashes. The approver's browser keeps a third, a nonce in a cookie. The broker receives the
145
+ authorization code only after the approval page proves it holds that nonce, and gives it once, to the one poll that
146
+ knows the poll secret. The code is useless without the verifier.
147
+
148
+ ```mermaid
149
+ sequenceDiagram
150
+ participant CLI as CLI on the headless machine
151
+ participant Broker as Broker
152
+ participant Person as Approver's browser
153
+ participant IdP as Sign-in provider
154
+
155
+ CLI->>Broker: Start: send hashes of the verifier and poll secret, and a requester label
156
+ Broker-->>CLI: Answer with a transaction id, a matching code and the approval URL
157
+ CLI->>CLI: Print the approval URL and the matching code
158
+ CLI->>Broker: Poll: ask whether the transaction is approved, sending the poll secret
159
+ Broker-->>CLI: Answer "pending"
160
+ Person->>Broker: Open the approval page, which asks the broker to describe the transaction
161
+ Broker-->>Person: Show the requester label and the matching code
162
+ Person->>Person: Check that the code matches, then press Approve
163
+ Person->>Broker: Bind the transaction to a hash of the browser's nonce
164
+ Person->>IdP: Sign in at the provider and return with an authorization code
165
+ Person->>Broker: Callback page completes the transaction with the nonce and the code
166
+ CLI->>Broker: Poll again, sending the poll secret
167
+ Broker-->>CLI: Hand over the authorization code, once
168
+ CLI->>IdP: Exchange the code with the verifier for tokens
169
+ IdP-->>CLI: Answer with the tokens, which the CLI stores
170
+ ```
171
+
172
+ The full contract, including the private operations and the page requirements, is in
173
+ [docs/protocol.md](docs/protocol.md). A client in another language can implement the CLI side from it and run
174
+ `features/remote_sign_in.feature`.
175
+
176
+ ## Deploying the broker
177
+
178
+ Each product deployment runs one broker, built from `infra/broker.yaml`.
179
+
180
+ 1. Build the Lambda zip. The build is deterministic, so the same source gives the same hash:
181
+
182
+ ```bash
183
+ python scripts/build_broker.py broker.zip
184
+ ```
185
+
186
+ It prints the SHA-256 and the `CodeKey` to use (`cli-login/broker-<sha256>.zip`). Upload the zip to your bucket
187
+ under that key.
188
+ 2. Deploy the template with these parameters:
189
+
190
+ | Parameter | Meaning |
191
+ | --- | --- |
192
+ | `CodeBucket`, `CodeKey` | Where the zip is; a new key deploys new code |
193
+ | `ClientId` | Your Cognito app client id; the broker serves only this client |
194
+ | `Targets` | JSON list of the API endpoints a CLI may sign in to |
195
+ | `ApprovalUrl` | Your approval page, an `https://` URL |
196
+ | `CallbackUri` | Your callback page, registered on the app client, an `https://` URL |
197
+ | `InvokerRoleArn` | The backend role that serves both pages and may invoke the broker |
198
+ | `ReservedConcurrency` | Most invocations at once (default 10) |
199
+ | `LogRetentionDays` | Log retention (default 30) |
200
+ | `AlarmTopicArn` | Optional SNS topic told when the broker is throttled |
201
+
202
+ The `BrokerEndpoint` output is the `broker_endpoint` in your `Target`.
203
+ 3. Serve the two pages from your product's own domain. `cli_login.broker.pages.ApprovalPages` gives you
204
+ framework-neutral handlers for `approve` and `callback`; you give it a function that invokes the broker.
205
+
206
+ The broker and the approval pages must run the same cli-login version. Products should pin an exact version, since
207
+ releases before 1.0 may change behaviour.
208
+
209
+ ## Where sessions are stored
210
+
211
+ Sessions live only in a secure store, tried in this order. There is no plaintext fallback: if nothing is
212
+ usable, the tool fails with the exact steps to fix it.
213
+
214
+ | Platform | Store (in order) | Lasts |
215
+ | --- | --- | --- |
216
+ | macOS | Keychain (service name = the product namespace, so existing sessions keep working) | until logout |
217
+ | Linux | 1. Secret Service (D-Bus), needs `pip install "cli-login[linux]"` and an unlocked keyring; 2. kernel keyring; 3. the memory agent | keyring: until logout; kernel keyring: until reboot, or the persistent keyring's expiry (3 days from last use by default); agent: until it stops |
218
+ | Windows | Credential Manager (`<namespace>/<key>`; values over 2,560 bytes are chunked) | until logout |
219
+
220
+ The memory agent is not available on Windows in this version. Set `CLI_LOGIN_STORE` to `keychain`,
221
+ `secret-service`, `kernel-keyring`, `agent` or `credential-manager` to choose a store explicitly (for operators
222
+ and tests). Any other value is refused.
223
+
224
+ ### Cloud machines and containers
225
+
226
+ 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:
228
+
229
+ ```bash
230
+ python -m cli_login.stores.agent serve --namespace <product-namespace> --background
231
+ python -m cli_login.stores.agent status --namespace <product-namespace>
232
+ python -m cli_login.stores.agent stop --namespace <product-namespace>
233
+ ```
234
+
235
+ A product mounts the same functions (`stores.agent.serve`, `start_background`, `status`, `stop`, `main`) under
236
+ its own CLI name, such as `<product> auth agent`. The agent holds sessions in memory only, behind a 0600 Unix
237
+ socket in a 0700 directory under the product's state directory (`~/.config/cli-login/<namespace>/agent`,
238
+ or the socket named by `CLI_LOGIN_AGENT_SOCKET`, which must sit in a private directory and be short
239
+ enough for a Unix socket). It checks the caller's user id on every connection, disables core dumps, and keeps
240
+ nothing on disk. After it stops, or the machine restarts, sign in again. Processes of the same user can use it,
241
+ as with ssh-agent.
242
+
243
+ ### Recovering from each failure
244
+
245
+ - **"No Secret Service is reachable"**: no D-Bus session or keyring daemon. On a server, use the memory agent.
246
+ - **"Secret Service keyring is locked"**: unlock it by signing in to the desktop session, then retry.
247
+ - **"cli-login[linux]"**: the optional dependency is missing; `pip install "cli-login[linux]"`.
248
+ - **"kernel keyring is unavailable"**: the kernel or container blocks `add_key`/`keyctl`. Use the memory agent,
249
+ or run the container with a seccomp profile that allows them.
250
+ - **"The memory agent is not running"**: run the `serve` command shown in the message, then sign in again.
251
+ - **Agent socket refused**: the directory or socket is not owned by you or not private; fix the mode (0700) or
252
+ remove the stale path and restart the agent.
253
+ - **"The stored session is damaged"** (Windows): run the login command again to replace it.
254
+ - **Credential Manager errors on Windows**: sign in to Windows normally (not a restricted service account) and
255
+ retry.
256
+
257
+ ## Security model and limits
258
+
259
+ - **Whoever approves first owns the session.** The approval URL and code are often relayed through chat. The
260
+ requester label, the matching code the approver must confirm, and `expected_email` narrow the risk. They do not
261
+ remove it, and the CLI always prints the identity it signed in as.
262
+ - **Tokens can leak.** They are short-lived and renewable, and logout revokes the refresh token. A stolen refresh
263
+ token works until it expires or is revoked.
264
+ - **The broker never holds a token.** It holds an authorization code until the right CLI collects it, and the
265
+ code is useless without the verifier.
266
+ - **Cognito only, for now.** The library targets Amazon Cognito user pools with the authorization-code flow and PKCE.
267
+ - **The broker is AWS-only** (Lambda and DynamoDB), and **the client is Python-only**. The protocol is written down
268
+ so other clients can follow it.
269
+ - **Not yet tested:** a real renewal run of 24 hours or more; the approval flow in real browsers (Chrome, Safari,
270
+ Firefox); and the macOS Keychain store beyond fakes. The Linux Secret Service and kernel keyring run against
271
+ real backends in CI.
272
+ - Same-user processes can use the memory agent, as with ssh-agent.
273
+
274
+ ## Origins
275
+
276
+ cli-login was originally developed for the Anthus products (Papyrus and Cyclotron) and is now maintained under
277
+ DataParade-io.
278
+
279
+ ## Development
280
+
281
+ ```bash
282
+ python -m pip install -e ".[dev]" # add ",linux" on Linux for Secret Service tests
283
+ python -m ruff check .
284
+ python -m pytest -q -rs
285
+ python -m behave features
286
+ ```
287
+
288
+ CI runs these on Ubuntu, macOS and Windows with Python 3.10 and 3.13. On Linux it also runs against a real Secret
289
+ Service and kernel keyring.
290
+
291
+ ## Releasing (maintainers)
292
+
293
+ Pushing to `main` (a pull request from `develop`) runs python-semantic-release on the conventional commits:
294
+ `feat:` and `fix:` (or a breaking change) bump the version in `pyproject.toml`, tag `vX.Y.Z` and create a GitHub
295
+ release; other commits release nothing. The `pypi` job then builds the sdist and wheel and publishes them with
296
+ OIDC trusted publishing, so no PyPI token is stored anywhere. Publishing needs a PyPI trusted publisher, registered
297
+ by the PyPI account owner: project `cli-login`, owner `DataParade-io`, repository `cli-login`, workflow
298
+ `release.yml`, environment `pypi`. The workflow's `pypi` environment must also exist in the repository settings.
299
+
300
+ ## License
301
+
302
+ MIT. See [LICENSE](LICENSE).
@@ -95,11 +95,19 @@ 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.
102
104
 
105
+ The local loopback sign-in (`Session.login`, not part of the broker protocol) offers the same enrollment through
106
+ `prepare=` and `enroll=`. `prepare(state)` returns `(nonce, enrollment)` before the authorize URL is built; the nonce
107
+ is sent as the OpenID `nonce`, and the ID token must carry it back or the login is refused (`nonce_mismatch`).
108
+ `enroll(tokens, state, enrollment)` then runs before anything is stored, and if it raises the tokens are discarded
109
+ and the refresh token is revoked on a best-effort basis.
110
+
103
111
  A network failure during the exchange is **not** retried, because the code may already be spent. The CLI starts
104
112
  again.
105
113
 
@@ -117,7 +125,7 @@ A failure answers `{"ok": false, "error": "unavailable"}` and says nothing more.
117
125
  | `operation` | Fields | Effect |
118
126
  |---|---|---|
119
127
  | `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. |
128
+ | `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
129
  | `complete` | `transaction`, `nonce`, `code` | Bound to approved, only when `sha256(nonce)` equals the bound hash |
122
130
  | `deny` | `transaction` | Pending or bound to denied |
123
131