cli-login 0.3.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. cli_login-0.4.1/PKG-INFO +341 -0
  2. cli_login-0.4.1/README.md +325 -0
  3. {cli_login-0.3.0 → cli_login-0.4.1}/docs/design.md +4 -1
  4. {cli_login-0.3.0 → cli_login-0.4.1}/docs/protocol.md +11 -0
  5. {cli_login-0.3.0 → cli_login-0.4.1}/features/login_session.feature +33 -0
  6. {cli_login-0.3.0 → cli_login-0.4.1}/features/remote_sign_in.feature +2 -0
  7. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/login_session_steps.py +95 -1
  8. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/remote_sign_in_steps.py +15 -1
  9. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/stores_agent_steps.py +16 -0
  10. {cli_login-0.3.0 → cli_login-0.4.1}/features/stores_agent.feature +5 -0
  11. cli_login-0.4.1/project/events/2026-10-31T15-05-12.601Z__80377f48-3218-4a3d-9f7e-9c4db44de2f0.json +23 -0
  12. cli_login-0.4.1/project/events/2026-10-31T15-27-42.778Z__65bd906f-6801-4df1-ae7b-2112c3629b32.json +12 -0
  13. cli_login-0.4.1/project/events/2026-10-31T15-27-43.836Z__5da8b7b7-4903-4767-a638-3cb0d95c32a0.json +12 -0
  14. cli_login-0.4.1/project/issues/cli-login-79ca92d5-4534-4bea-bfe1-aaeaa5037b49.json +28 -0
  15. {cli_login-0.3.0 → cli_login-0.4.1}/pyproject.toml +2 -2
  16. cli_login-0.4.1/src/cli_login/__init__.py +4 -0
  17. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/broker/handler_test.py +25 -0
  18. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/broker/pages_test.py +44 -0
  19. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/oauth.py +3 -0
  20. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/remote.py +32 -2
  21. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/remote_test.py +54 -0
  22. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/session.py +41 -4
  23. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/session_test.py +72 -0
  24. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/agent.py +12 -5
  25. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/agent_test.py +27 -0
  26. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/testing.py +5 -2
  27. cli_login-0.3.0/PKG-INFO +0 -104
  28. cli_login-0.3.0/README.md +0 -88
  29. cli_login-0.3.0/src/cli_login/__init__.py +0 -4
  30. {cli_login-0.3.0 → cli_login-0.4.1}/.claude/settings.json +0 -0
  31. {cli_login-0.3.0 → cli_login-0.4.1}/.cursorignore +0 -0
  32. {cli_login-0.3.0 → cli_login-0.4.1}/.github/workflows/release.yml +0 -0
  33. {cli_login-0.3.0 → cli_login-0.4.1}/.github/workflows/tests.yml +0 -0
  34. {cli_login-0.3.0 → cli_login-0.4.1}/.gitignore +0 -0
  35. {cli_login-0.3.0 → cli_login-0.4.1}/.kanbus.yml +0 -0
  36. {cli_login-0.3.0 → cli_login-0.4.1}/.vscode/settings.json +0 -0
  37. {cli_login-0.3.0 → cli_login-0.4.1}/AGENTS.md +0 -0
  38. {cli_login-0.3.0 → cli_login-0.4.1}/CONTRIBUTING_AGENT.md +0 -0
  39. {cli_login-0.3.0 → cli_login-0.4.1}/CONTRIBUTING_AGENT.template.md +0 -0
  40. {cli_login-0.3.0 → cli_login-0.4.1}/LICENSE +0 -0
  41. {cli_login-0.3.0 → cli_login-0.4.1}/features/environment.py +0 -0
  42. {cli_login-0.3.0 → cli_login-0.4.1}/features/locks.feature +0 -0
  43. {cli_login-0.3.0 → cli_login-0.4.1}/features/oauth_provider.feature +0 -0
  44. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/locks_steps.py +0 -0
  45. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/oauth_provider_steps.py +0 -0
  46. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/store_common_steps.py +0 -0
  47. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/stores_kernel_keyring_steps.py +0 -0
  48. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/stores_macos_steps.py +0 -0
  49. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/stores_secret_service_steps.py +0 -0
  50. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/stores_selection_steps.py +0 -0
  51. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/stores_windows_steps.py +0 -0
  52. {cli_login-0.3.0 → cli_login-0.4.1}/features/steps/target_steps.py +0 -0
  53. {cli_login-0.3.0 → cli_login-0.4.1}/features/stores_agent_windows.feature +0 -0
  54. {cli_login-0.3.0 → cli_login-0.4.1}/features/stores_kernel_keyring.feature +0 -0
  55. {cli_login-0.3.0 → cli_login-0.4.1}/features/stores_macos.feature +0 -0
  56. {cli_login-0.3.0 → cli_login-0.4.1}/features/stores_secret_service.feature +0 -0
  57. {cli_login-0.3.0 → cli_login-0.4.1}/features/stores_selection.feature +0 -0
  58. {cli_login-0.3.0 → cli_login-0.4.1}/features/stores_windows.feature +0 -0
  59. {cli_login-0.3.0 → cli_login-0.4.1}/features/target.feature +0 -0
  60. {cli_login-0.3.0 → cli_login-0.4.1}/infra/broker.yaml +0 -0
  61. {cli_login-0.3.0 → cli_login-0.4.1}/infra/broker_template_test.py +0 -0
  62. {cli_login-0.3.0 → cli_login-0.4.1}/project/AGENTS.md +0 -0
  63. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.293Z__b9260b7c-7ee0-4df4-a3e0-618bf6410f7f.json +0 -0
  64. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.309Z__8329b8bd-f933-4597-b6ef-11d3b9e83819.json +0 -0
  65. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.319Z__02962f12-cc16-413f-94b1-9ac250cfe43a.json +0 -0
  66. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.329Z__78d07504-8941-45fc-aa87-a883260179b6.json +0 -0
  67. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.339Z__e2e9e373-9425-47cc-ab2e-97bd17a904b3.json +0 -0
  68. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.350Z__811313ad-5bef-4918-a09b-0c6fbee06c27.json +0 -0
  69. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.363Z__92385224-7ace-40cd-9f1f-09f3a491c086.json +0 -0
  70. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T10-53-42.529Z__b801b764-9abb-4714-9c9a-57f98ec6b120.json +0 -0
  71. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-43-05.049Z__5f67cee7-ed11-4602-b91a-cc0dc054a000.json +0 -0
  72. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-43-05.062Z__447020e8-b028-4fbe-9207-869e9aff8ee6.json +0 -0
  73. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-54-51.436Z__48a79b75-ead6-4a2d-a31c-bb9cedbaaabc.json +0 -0
  74. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-54-51.454Z__222fa29e-fada-4e25-a64d-e259c99cbb24.json +0 -0
  75. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-54-51.463Z__0f87a247-a94e-4611-a7a5-0a80f16b50a0.json +0 -0
  76. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-56-05.842Z__3faa8c4c-7c22-41ab-b86c-2e12e622eb6a.json +0 -0
  77. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-56-05.852Z__9c5e7de9-0269-47d0-9dab-e58850e21ac9.json +0 -0
  78. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.140Z__284dc920-8b9e-47e3-b924-4233c908a54e.json +0 -0
  79. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.154Z__08903f6c-aab5-4ea7-877c-cc67ed9cf832.json +0 -0
  80. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.163Z__9d412c33-67bf-4bd4-83af-0c55e972f94e.json +0 -0
  81. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.173Z__ed2149f3-4c1f-43e4-b38d-0f9b295a0234.json +0 -0
  82. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.183Z__8cc06e66-99f6-4529-b312-a70cbaff1ca9.json +0 -0
  83. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.193Z__3ee6c0c7-52ca-40c3-b7d2-5e0950b0dce2.json +0 -0
  84. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T11-58-11.250Z__57d95af6-0bf0-4cc6-b610-82f4c40fbff1.json +0 -0
  85. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T12-00-38.672Z__b391bfe3-c3b0-4a9d-8839-1381a6577b91.json +0 -0
  86. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T12-00-38.686Z__153c3c9d-7b02-43af-8d50-888e8a9d965d.json +0 -0
  87. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T12-07-40.329Z__ba26d852-684d-4347-bae5-d347cb69fddd.json +0 -0
  88. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T12-07-40.337Z__602e30d8-8ef7-40f3-9fe6-505e735f3b89.json +0 -0
  89. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T12-10-43.690Z__c2398383-7bf7-437e-8b4c-7aba9633cfbc.json +0 -0
  90. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T12-10-44.027Z__0644b779-33ed-49c7-91dc-166760f42aae.json +0 -0
  91. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T12-28-43.772Z__f550a131-02c7-44c0-b76a-3694222d74a9.json +0 -0
  92. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T14-01-17.888Z__bf995622-7f63-4936-9891-4cebf7208cf5.json +0 -0
  93. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/2026-10-30T14-01-17.900Z__e9675700-922f-449e-8b9f-9779917ce373.json +0 -0
  94. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/AGENTS.md +0 -0
  95. {cli_login-0.3.0 → cli_login-0.4.1}/project/events/DO_NOT_EDIT +0 -0
  96. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/AGENTS.md +0 -0
  97. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/DO_NOT_EDIT +0 -0
  98. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/cli-login-04de9a1b-0c3e-4589-b9b5-ae30cd3cdd18.json +0 -0
  99. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/cli-login-1b4a091f-2bfc-405c-be6e-b787b4bb4696.json +0 -0
  100. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/cli-login-31750ff1-8d69-4b03-b953-82da220c1a9f.json +0 -0
  101. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4.json +0 -0
  102. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/cli-login-361d5c51-7951-4e46-9c57-a64cb6f8058f.json +0 -0
  103. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/cli-login-85e67311-6ca7-4c57-9e44-4d414ebc618f.json +0 -0
  104. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/cli-login-96ed2301-f038-4d76-84bb-b670184ab538.json +0 -0
  105. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/cli-login-9a02d68d-d3ff-4dfd-a319-2c411f05fddd.json +0 -0
  106. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/cli-login-cccc972a-7df2-4795-9f9b-d8805dfc142e.json +0 -0
  107. {cli_login-0.3.0 → cli_login-0.4.1}/project/issues/cli-login-ff76cbbc-d47b-49d5-91e1-4aa2ae15e99f.json +0 -0
  108. {cli_login-0.3.0 → cli_login-0.4.1}/project/wiki/index.md +0 -0
  109. {cli_login-0.3.0 → cli_login-0.4.1}/project/wiki/whats-next.md +0 -0
  110. {cli_login-0.3.0 → cli_login-0.4.1}/scripts/build_broker.py +0 -0
  111. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/__init___test.py +0 -0
  112. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/broker/__init__.py +0 -0
  113. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/broker/handler.py +0 -0
  114. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/broker/pages.py +0 -0
  115. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/broker/throttle.py +0 -0
  116. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/broker/transactions.py +0 -0
  117. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/broker/transactions_test.py +0 -0
  118. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/concurrency_test.py +0 -0
  119. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/errors.py +0 -0
  120. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/errors_test.py +0 -0
  121. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/locks.py +0 -0
  122. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/locks_test.py +0 -0
  123. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/oauth_test.py +0 -0
  124. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/__init__.py +0 -0
  125. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/kernel_keyring.py +0 -0
  126. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/kernel_keyring_test.py +0 -0
  127. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/macos.py +0 -0
  128. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/macos_test.py +0 -0
  129. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/secret_service.py +0 -0
  130. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/secret_service_test.py +0 -0
  131. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/stores_test.py +0 -0
  132. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/windows.py +0 -0
  133. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/stores/windows_test.py +0 -0
  134. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/target.py +0 -0
  135. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/target_test.py +0 -0
  136. {cli_login-0.3.0 → cli_login-0.4.1}/src/cli_login/testing_processes.py +0 -0
@@ -0,0 +1,341 @@
1
+ Metadata-Version: 2.5
2
+ Name: cli-login
3
+ Version: 0.4.1
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
+ ## The product's app client
226
+
227
+ cli-login creates no Cognito client; each product signs its CLI in with an app client it defines. Use these settings
228
+ unless the product has a reviewed reason not to:
229
+
230
+ | Setting | Default |
231
+ | --- | --- |
232
+ | OAuth flow | Authorization code only, with PKCE (S256) |
233
+ | Client secret | None (a public client) |
234
+ | Explicit auth flows | `ALLOW_REFRESH_TOKEN_AUTH` only |
235
+ | Refresh token | 7 days |
236
+ | Access and ID tokens | 60 minutes |
237
+ | Token revocation | On, so `logout()` can revoke the refresh token |
238
+ | Callbacks | Exact URLs: one fixed loopback port for local sign-in and `https://<product-domain>/cli/callback` for remote sign-in |
239
+
240
+ Seven days lets an agent on a cloud machine keep working for a week without a person signing in again, while a
241
+ lost or leaked refresh token stops working within the week. Access tokens stay short, so an API sees a revocation
242
+ within an hour. Cognito matches callbacks exactly, with no port wildcard, so the loopback port is fixed and
243
+ registered.
244
+
245
+ ## Where sessions are stored
246
+
247
+ Sessions live only in a secure store, tried in this order. There is no plaintext fallback: if nothing is
248
+ usable, the tool fails with the exact steps to fix it.
249
+
250
+ | Platform | Store (in order) | Lasts |
251
+ | --- | --- | --- |
252
+ | macOS | Keychain (service name = the product namespace, so existing sessions keep working) | until logout |
253
+ | 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 |
254
+ | Windows | Credential Manager (`<namespace>/<key>`; values over 2,560 bytes are chunked) | until logout |
255
+
256
+ The memory agent is not available on Windows in this version. Set `CLI_LOGIN_STORE` to `keychain`,
257
+ `secret-service`, `kernel-keyring`, `agent` or `credential-manager` to choose a store explicitly (for operators
258
+ and tests). Any other value is refused.
259
+
260
+ ### Cloud machines and containers
261
+
262
+ A cloud machine usually has no desktop keyring, and Docker's default seccomp profile blocks the kernel keyring
263
+ calls. Start the memory agent once per product, then sign in again. The error message prints the exact command,
264
+ with the full path of the interpreter that has `cli-login` installed; use that one. A bare `python` cannot import
265
+ the library when a product installs it in a private environment (`pipx`, `uv tool`), so in these examples
266
+ `python` stands for that interpreter:
267
+
268
+ ```bash
269
+ python -m cli_login.stores.agent serve --namespace <product-namespace> --background
270
+ python -m cli_login.stores.agent status --namespace <product-namespace>
271
+ python -m cli_login.stores.agent stop --namespace <product-namespace>
272
+ ```
273
+
274
+ A product mounts the same functions (`stores.agent.serve`, `start_background`, `status`, `stop`, `main`) under
275
+ its own CLI name, such as `<product> auth agent`. The agent holds sessions in memory only, behind a 0600 Unix
276
+ socket in a 0700 directory under the product's state directory (`~/.config/cli-login/<namespace>/agent`,
277
+ or the socket named by `CLI_LOGIN_AGENT_SOCKET`, which must sit in a private directory and be short
278
+ enough for a Unix socket). It checks the caller's user id on every connection, disables core dumps, and keeps
279
+ nothing on disk. After it stops, or the machine restarts, sign in again. Processes of the same user can use it,
280
+ as with ssh-agent.
281
+
282
+ ### Recovering from each failure
283
+
284
+ - **"No Secret Service is reachable"**: no D-Bus session or keyring daemon. On a server, use the memory agent.
285
+ - **"Secret Service keyring is locked"**: unlock it by signing in to the desktop session, then retry.
286
+ - **"cli-login[linux]"**: the optional dependency is missing; `pip install "cli-login[linux]"`.
287
+ - **"kernel keyring is unavailable"**: the kernel or container blocks `add_key`/`keyctl`. Use the memory agent,
288
+ or run the container with a seccomp profile that allows them.
289
+ - **"The memory agent is not running"**: run the `serve` command shown in the message, then sign in again.
290
+ - **Agent socket refused**: the directory or socket is not owned by you or not private; fix the mode (0700) or
291
+ remove the stale path and restart the agent.
292
+ - **"The stored session is damaged"** (Windows): run the login command again to replace it.
293
+ - **Credential Manager errors on Windows**: sign in to Windows normally (not a restricted service account) and
294
+ retry.
295
+
296
+ ## Security model and limits
297
+
298
+ - **Whoever approves first owns the session.** The approval URL and code are often relayed through chat. The
299
+ requester label, the matching code the approver must confirm, and `expected_email` narrow the risk. They do not
300
+ remove it, and the CLI always prints the identity it signed in as.
301
+ - **Tokens can leak.** They are short-lived and renewable, and logout revokes the refresh token. A stolen refresh
302
+ token works until it is revoked or expires (7 days with the default app client).
303
+ - **The broker never holds a token.** It holds an authorization code until the right CLI collects it, and the
304
+ code is useless without the verifier.
305
+ - **Cognito only, for now.** The library targets Amazon Cognito user pools with the authorization-code flow and PKCE.
306
+ - **The broker is AWS-only** (Lambda and DynamoDB), and **the client is Python-only**. The protocol is written down
307
+ so other clients can follow it.
308
+ - **Not yet tested:** a real renewal run of 24 hours or more; the approval flow in real browsers (Chrome, Safari,
309
+ Firefox); and the macOS Keychain store beyond fakes. The Linux Secret Service and kernel keyring run against
310
+ real backends in CI.
311
+ - Same-user processes can use the memory agent, as with ssh-agent.
312
+
313
+ ## Origins
314
+
315
+ cli-login was originally developed for the Anthus products (Papyrus and Cyclotron) and is now maintained under
316
+ DataParade-io.
317
+
318
+ ## Development
319
+
320
+ ```bash
321
+ python -m pip install -e ".[dev]" # add ",linux" on Linux for Secret Service tests
322
+ python -m ruff check .
323
+ python -m pytest -q -rs
324
+ python -m behave features
325
+ ```
326
+
327
+ CI runs these on Ubuntu, macOS and Windows with Python 3.10 and 3.13. On Linux it also runs against a real Secret
328
+ Service and kernel keyring.
329
+
330
+ ## Releasing (maintainers)
331
+
332
+ Pushing to `main` (a pull request from `develop`) runs python-semantic-release on the conventional commits:
333
+ `feat:` and `fix:` (or a breaking change) bump the version in `pyproject.toml`, tag `vX.Y.Z` and create a GitHub
334
+ release; other commits release nothing. The `pypi` job then builds the sdist and wheel and publishes them with
335
+ OIDC trusted publishing, so no PyPI token is stored anywhere. Publishing needs a PyPI trusted publisher, registered
336
+ by the PyPI account owner: project `cli-login`, owner `DataParade-io`, repository `cli-login`, workflow
337
+ `release.yml`, environment `pypi`. The workflow's `pypi` environment must also exist in the repository settings.
338
+
339
+ ## License
340
+
341
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,325 @@
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
+ ## The product's app client
210
+
211
+ cli-login creates no Cognito client; each product signs its CLI in with an app client it defines. Use these settings
212
+ unless the product has a reviewed reason not to:
213
+
214
+ | Setting | Default |
215
+ | --- | --- |
216
+ | OAuth flow | Authorization code only, with PKCE (S256) |
217
+ | Client secret | None (a public client) |
218
+ | Explicit auth flows | `ALLOW_REFRESH_TOKEN_AUTH` only |
219
+ | Refresh token | 7 days |
220
+ | Access and ID tokens | 60 minutes |
221
+ | Token revocation | On, so `logout()` can revoke the refresh token |
222
+ | Callbacks | Exact URLs: one fixed loopback port for local sign-in and `https://<product-domain>/cli/callback` for remote sign-in |
223
+
224
+ Seven days lets an agent on a cloud machine keep working for a week without a person signing in again, while a
225
+ lost or leaked refresh token stops working within the week. Access tokens stay short, so an API sees a revocation
226
+ within an hour. Cognito matches callbacks exactly, with no port wildcard, so the loopback port is fixed and
227
+ registered.
228
+
229
+ ## Where sessions are stored
230
+
231
+ Sessions live only in a secure store, tried in this order. There is no plaintext fallback: if nothing is
232
+ usable, the tool fails with the exact steps to fix it.
233
+
234
+ | Platform | Store (in order) | Lasts |
235
+ | --- | --- | --- |
236
+ | macOS | Keychain (service name = the product namespace, so existing sessions keep working) | until logout |
237
+ | 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 |
238
+ | Windows | Credential Manager (`<namespace>/<key>`; values over 2,560 bytes are chunked) | until logout |
239
+
240
+ The memory agent is not available on Windows in this version. Set `CLI_LOGIN_STORE` to `keychain`,
241
+ `secret-service`, `kernel-keyring`, `agent` or `credential-manager` to choose a store explicitly (for operators
242
+ and tests). Any other value is refused.
243
+
244
+ ### Cloud machines and containers
245
+
246
+ A cloud machine usually has no desktop keyring, and Docker's default seccomp profile blocks the kernel keyring
247
+ calls. Start the memory agent once per product, then sign in again. The error message prints the exact command,
248
+ with the full path of the interpreter that has `cli-login` installed; use that one. A bare `python` cannot import
249
+ the library when a product installs it in a private environment (`pipx`, `uv tool`), so in these examples
250
+ `python` stands for that interpreter:
251
+
252
+ ```bash
253
+ python -m cli_login.stores.agent serve --namespace <product-namespace> --background
254
+ python -m cli_login.stores.agent status --namespace <product-namespace>
255
+ python -m cli_login.stores.agent stop --namespace <product-namespace>
256
+ ```
257
+
258
+ A product mounts the same functions (`stores.agent.serve`, `start_background`, `status`, `stop`, `main`) under
259
+ its own CLI name, such as `<product> auth agent`. The agent holds sessions in memory only, behind a 0600 Unix
260
+ socket in a 0700 directory under the product's state directory (`~/.config/cli-login/<namespace>/agent`,
261
+ or the socket named by `CLI_LOGIN_AGENT_SOCKET`, which must sit in a private directory and be short
262
+ enough for a Unix socket). It checks the caller's user id on every connection, disables core dumps, and keeps
263
+ nothing on disk. After it stops, or the machine restarts, sign in again. Processes of the same user can use it,
264
+ as with ssh-agent.
265
+
266
+ ### Recovering from each failure
267
+
268
+ - **"No Secret Service is reachable"**: no D-Bus session or keyring daemon. On a server, use the memory agent.
269
+ - **"Secret Service keyring is locked"**: unlock it by signing in to the desktop session, then retry.
270
+ - **"cli-login[linux]"**: the optional dependency is missing; `pip install "cli-login[linux]"`.
271
+ - **"kernel keyring is unavailable"**: the kernel or container blocks `add_key`/`keyctl`. Use the memory agent,
272
+ or run the container with a seccomp profile that allows them.
273
+ - **"The memory agent is not running"**: run the `serve` command shown in the message, then sign in again.
274
+ - **Agent socket refused**: the directory or socket is not owned by you or not private; fix the mode (0700) or
275
+ remove the stale path and restart the agent.
276
+ - **"The stored session is damaged"** (Windows): run the login command again to replace it.
277
+ - **Credential Manager errors on Windows**: sign in to Windows normally (not a restricted service account) and
278
+ retry.
279
+
280
+ ## Security model and limits
281
+
282
+ - **Whoever approves first owns the session.** The approval URL and code are often relayed through chat. The
283
+ requester label, the matching code the approver must confirm, and `expected_email` narrow the risk. They do not
284
+ remove it, and the CLI always prints the identity it signed in as.
285
+ - **Tokens can leak.** They are short-lived and renewable, and logout revokes the refresh token. A stolen refresh
286
+ token works until it is revoked or expires (7 days with the default app client).
287
+ - **The broker never holds a token.** It holds an authorization code until the right CLI collects it, and the
288
+ code is useless without the verifier.
289
+ - **Cognito only, for now.** The library targets Amazon Cognito user pools with the authorization-code flow and PKCE.
290
+ - **The broker is AWS-only** (Lambda and DynamoDB), and **the client is Python-only**. The protocol is written down
291
+ so other clients can follow it.
292
+ - **Not yet tested:** a real renewal run of 24 hours or more; the approval flow in real browsers (Chrome, Safari,
293
+ Firefox); and the macOS Keychain store beyond fakes. The Linux Secret Service and kernel keyring run against
294
+ real backends in CI.
295
+ - Same-user processes can use the memory agent, as with ssh-agent.
296
+
297
+ ## Origins
298
+
299
+ cli-login was originally developed for the Anthus products (Papyrus and Cyclotron) and is now maintained under
300
+ DataParade-io.
301
+
302
+ ## Development
303
+
304
+ ```bash
305
+ python -m pip install -e ".[dev]" # add ",linux" on Linux for Secret Service tests
306
+ python -m ruff check .
307
+ python -m pytest -q -rs
308
+ python -m behave features
309
+ ```
310
+
311
+ CI runs these on Ubuntu, macOS and Windows with Python 3.10 and 3.13. On Linux it also runs against a real Secret
312
+ Service and kernel keyring.
313
+
314
+ ## Releasing (maintainers)
315
+
316
+ Pushing to `main` (a pull request from `develop`) runs python-semantic-release on the conventional commits:
317
+ `feat:` and `fix:` (or a breaking change) bump the version in `pyproject.toml`, tag `vX.Y.Z` and create a GitHub
318
+ release; other commits release nothing. The `pypi` job then builds the sdist and wheel and publishes them with
319
+ OIDC trusted publishing, so no PyPI token is stored anywhere. Publishing needs a PyPI trusted publisher, registered
320
+ by the PyPI account owner: project `cli-login`, owner `DataParade-io`, repository `cli-login`, workflow
321
+ `release.yml`, environment `pypi`. The workflow's `pypi` environment must also exist in the repository settings.
322
+
323
+ ## License
324
+
325
+ MIT. See [LICENSE](LICENSE).