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.
- cli_login-0.4.0/PKG-INFO +318 -0
- cli_login-0.4.0/README.md +302 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/docs/protocol.md +10 -2
- {cli_login-0.2.0 → cli_login-0.4.0}/features/login_session.feature +33 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/login_session_steps.py +95 -1
- cli_login-0.4.0/project/events/2026-10-30T14-01-17.888Z__bf995622-7f63-4936-9891-4cebf7208cf5.json +12 -0
- cli_login-0.4.0/project/events/2026-10-30T14-01-17.900Z__e9675700-922f-449e-8b9f-9779917ce373.json +12 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-96ed2301-f038-4d76-84bb-b670184ab538.json +10 -3
- {cli_login-0.2.0 → cli_login-0.4.0}/pyproject.toml +2 -2
- cli_login-0.4.0/src/cli_login/__init__.py +4 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/handler.py +17 -2
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/handler_test.py +31 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/pages.py +16 -2
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/pages_test.py +39 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/oauth.py +3 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/remote.py +12 -1
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/remote_test.py +43 -3
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/session.py +41 -4
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/session_test.py +72 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/testing.py +5 -2
- cli_login-0.2.0/PKG-INFO +0 -104
- cli_login-0.2.0/README.md +0 -88
- cli_login-0.2.0/src/cli_login/__init__.py +0 -4
- {cli_login-0.2.0 → cli_login-0.4.0}/.claude/settings.json +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/.cursorignore +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/.github/workflows/release.yml +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/.github/workflows/tests.yml +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/.gitignore +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/.kanbus.yml +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/.vscode/settings.json +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/AGENTS.md +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/CONTRIBUTING_AGENT.md +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/CONTRIBUTING_AGENT.template.md +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/LICENSE +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/docs/design.md +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/environment.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/locks.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/oauth_provider.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/remote_sign_in.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/locks_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/oauth_provider_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/remote_sign_in_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/store_common_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_agent_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_kernel_keyring_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_macos_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_secret_service_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_selection_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/stores_windows_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/steps/target_steps.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_agent.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_agent_windows.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_kernel_keyring.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_macos.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_secret_service.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_selection.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/stores_windows.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/features/target.feature +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/infra/broker.yaml +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/infra/broker_template_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/AGENTS.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {cli_login-0.2.0 → cli_login-0.4.0}/project/events/AGENTS.md +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/events/DO_NOT_EDIT +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/AGENTS.md +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/DO_NOT_EDIT +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-04de9a1b-0c3e-4589-b9b5-ae30cd3cdd18.json +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-1b4a091f-2bfc-405c-be6e-b787b4bb4696.json +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-31750ff1-8d69-4b03-b953-82da220c1a9f.json +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4.json +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-361d5c51-7951-4e46-9c57-a64cb6f8058f.json +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-85e67311-6ca7-4c57-9e44-4d414ebc618f.json +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-9a02d68d-d3ff-4dfd-a319-2c411f05fddd.json +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-cccc972a-7df2-4795-9f9b-d8805dfc142e.json +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/issues/cli-login-ff76cbbc-d47b-49d5-91e1-4aa2ae15e99f.json +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/wiki/index.md +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/project/wiki/whats-next.md +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/scripts/build_broker.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/__init___test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/__init__.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/throttle.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/transactions.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/broker/transactions_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/concurrency_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/errors.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/errors_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/locks.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/locks_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/oauth_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/__init__.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/agent.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/agent_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/kernel_keyring.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/kernel_keyring_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/macos.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/macos_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/secret_service.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/secret_service_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/stores_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/windows.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/stores/windows_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/target.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/target_test.py +0 -0
- {cli_login-0.2.0 → cli_login-0.4.0}/src/cli_login/testing_processes.py +0 -0
cli_login-0.4.0/PKG-INFO
ADDED
|
@@ -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
|
|