cli-login 0.1.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cli_login-0.1.0 → cli_login-0.3.0}/PKG-INFO +1 -1
- {cli_login-0.1.0 → cli_login-0.3.0}/docs/protocol.md +7 -2
- {cli_login-0.1.0 → cli_login-0.3.0}/infra/broker.yaml +25 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/infra/broker_template_test.py +7 -0
- cli_login-0.3.0/project/events/2026-10-30T14-01-17.888Z__bf995622-7f63-4936-9891-4cebf7208cf5.json +12 -0
- cli_login-0.3.0/project/events/2026-10-30T14-01-17.900Z__e9675700-922f-449e-8b9f-9779917ce373.json +12 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-96ed2301-f038-4d76-84bb-b670184ab538.json +10 -3
- {cli_login-0.1.0 → cli_login-0.3.0}/pyproject.toml +1 -1
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/__init__.py +1 -1
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/handler.py +21 -2
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/handler_test.py +50 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/pages.py +16 -2
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/pages_test.py +39 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/throttle.py +6 -3
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/remote.py +12 -1
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/remote_test.py +43 -3
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/session.py +24 -1
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/session_test.py +60 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/testing.py +10 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/.claude/settings.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/.cursorignore +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/.github/workflows/release.yml +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/.github/workflows/tests.yml +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/.gitignore +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/.kanbus.yml +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/.vscode/settings.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/AGENTS.md +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/CONTRIBUTING_AGENT.md +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/CONTRIBUTING_AGENT.template.md +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/LICENSE +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/README.md +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/docs/design.md +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/environment.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/locks.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/login_session.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/oauth_provider.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/remote_sign_in.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/locks_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/login_session_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/oauth_provider_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/remote_sign_in_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/store_common_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_agent_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_kernel_keyring_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_macos_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_secret_service_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_selection_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_windows_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/target_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_agent.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_agent_windows.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_kernel_keyring.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_macos.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_secret_service.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_selection.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_windows.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/features/target.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/AGENTS.md +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.293Z__b9260b7c-7ee0-4df4-a3e0-618bf6410f7f.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.309Z__8329b8bd-f933-4597-b6ef-11d3b9e83819.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.319Z__02962f12-cc16-413f-94b1-9ac250cfe43a.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.329Z__78d07504-8941-45fc-aa87-a883260179b6.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.339Z__e2e9e373-9425-47cc-ab2e-97bd17a904b3.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.350Z__811313ad-5bef-4918-a09b-0c6fbee06c27.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T10-52-21.363Z__92385224-7ace-40cd-9f1f-09f3a491c086.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T10-53-42.529Z__b801b764-9abb-4714-9c9a-57f98ec6b120.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-43-05.049Z__5f67cee7-ed11-4602-b91a-cc0dc054a000.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-43-05.062Z__447020e8-b028-4fbe-9207-869e9aff8ee6.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-54-51.436Z__48a79b75-ead6-4a2d-a31c-bb9cedbaaabc.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-54-51.454Z__222fa29e-fada-4e25-a64d-e259c99cbb24.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-54-51.463Z__0f87a247-a94e-4611-a7a5-0a80f16b50a0.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-56-05.842Z__3faa8c4c-7c22-41ab-b86c-2e12e622eb6a.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-56-05.852Z__9c5e7de9-0269-47d0-9dab-e58850e21ac9.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.140Z__284dc920-8b9e-47e3-b924-4233c908a54e.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.154Z__08903f6c-aab5-4ea7-877c-cc67ed9cf832.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.163Z__9d412c33-67bf-4bd4-83af-0c55e972f94e.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.173Z__ed2149f3-4c1f-43e4-b38d-0f9b295a0234.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.183Z__8cc06e66-99f6-4529-b312-a70cbaff1ca9.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-57-13.193Z__3ee6c0c7-52ca-40c3-b7d2-5e0950b0dce2.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T11-58-11.250Z__57d95af6-0bf0-4cc6-b610-82f4c40fbff1.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T12-00-38.672Z__b391bfe3-c3b0-4a9d-8839-1381a6577b91.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T12-00-38.686Z__153c3c9d-7b02-43af-8d50-888e8a9d965d.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T12-07-40.329Z__ba26d852-684d-4347-bae5-d347cb69fddd.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T12-07-40.337Z__602e30d8-8ef7-40f3-9fe6-505e735f3b89.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T12-10-43.690Z__c2398383-7bf7-437e-8b4c-7aba9633cfbc.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T12-10-44.027Z__0644b779-33ed-49c7-91dc-166760f42aae.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/2026-10-30T12-28-43.772Z__f550a131-02c7-44c0-b76a-3694222d74a9.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/AGENTS.md +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/events/DO_NOT_EDIT +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/AGENTS.md +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/DO_NOT_EDIT +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-04de9a1b-0c3e-4589-b9b5-ae30cd3cdd18.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-1b4a091f-2bfc-405c-be6e-b787b4bb4696.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-31750ff1-8d69-4b03-b953-82da220c1a9f.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-361d5c51-7951-4e46-9c57-a64cb6f8058f.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-85e67311-6ca7-4c57-9e44-4d414ebc618f.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-9a02d68d-d3ff-4dfd-a319-2c411f05fddd.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-cccc972a-7df2-4795-9f9b-d8805dfc142e.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-ff76cbbc-d47b-49d5-91e1-4aa2ae15e99f.json +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/wiki/index.md +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/project/wiki/whats-next.md +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/scripts/build_broker.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/__init___test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/__init__.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/transactions.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/transactions_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/concurrency_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/errors.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/errors_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/locks.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/locks_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/oauth.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/oauth_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/__init__.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/agent.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/agent_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/kernel_keyring.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/kernel_keyring_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/macos.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/macos_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/secret_service.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/secret_service_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/stores_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/windows.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/windows_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/target.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/target_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/testing_processes.py +0 -0
|
@@ -95,7 +95,9 @@ The CLI checks:
|
|
|
95
95
|
- that `redirect_uri` equals its target's `remote_callback_uri`, and that the `approval_url` from `/start` is
|
|
96
96
|
on that URI's origin. A target with a broker must name its remote callback, even when its local sign-in uses a
|
|
97
97
|
loopback callback;
|
|
98
|
-
- that the ID token's `nonce` equals its own
|
|
98
|
+
- that the ID token's `nonce` equals its own. The one exception: a CLI that asked to enroll (`enroll=` in the
|
|
99
|
+
Python client) checks the nonce the poll handed over, if there is one. It then runs its enroll step, with its
|
|
100
|
+
tokens, the transaction and the `enrollment` string, before storing anything;
|
|
99
101
|
- that the approver's email is the expected one, when one is given.
|
|
100
102
|
|
|
101
103
|
It then passes the response to the session store, which checks issuer, audience, `token_use` and expiry.
|
|
@@ -117,7 +119,7 @@ A failure answers `{"ok": false, "error": "unavailable"}` and says nothing more.
|
|
|
117
119
|
| `operation` | Fields | Effect |
|
|
118
120
|
|---|---|---|
|
|
119
121
|
| `describe` | `transaction` | For a pending, unexpired transaction: `client_id`, `target`, `requester`, `matching_code`, `code_challenge`, `nonce`, `redirect_uri`, `expires_at` |
|
|
120
|
-
| `bind` | `transaction`, `nonce_hash` | Pending to bound, once. After this, nobody else can approve it. |
|
|
122
|
+
| `bind` | `transaction`, `nonce_hash`, optional `nonce` and `enrollment` | Pending to bound, once. After this, nobody else can approve it. A product whose sign-in enrolls each login (the Cyclotron lab, at Prospectus) sets the OpenID `nonce` its approval page uses and an opaque `enrollment` string. The poll hands both to the CLI with the code. |
|
|
121
123
|
| `complete` | `transaction`, `nonce`, `code` | Bound to approved, only when `sha256(nonce)` equals the bound hash |
|
|
122
124
|
| `deny` | `transaction` | Pending or bound to denied |
|
|
123
125
|
|
|
@@ -151,3 +153,6 @@ Every page sends:
|
|
|
151
153
|
every read.
|
|
152
154
|
- The poll interval starts at 5 seconds, and each `slow_down` adds 5.
|
|
153
155
|
- The rate limits per caller are 10 starts and 120 polls a minute.
|
|
156
|
+
- All callers together share a ceiling of 100 starts a minute. Past it, every start answers `429` with
|
|
157
|
+
`retry_after` until the window ends. The broker logs `cli_login_start_ceiling`, and the template turns that log
|
|
158
|
+
line into an alarm.
|
|
@@ -138,6 +138,31 @@ Resources:
|
|
|
138
138
|
Action: lambda:InvokeFunction
|
|
139
139
|
Principal: !Ref InvokerRoleArn
|
|
140
140
|
|
|
141
|
+
StartCeilingFilter:
|
|
142
|
+
Type: AWS::Logs::MetricFilter
|
|
143
|
+
Properties:
|
|
144
|
+
LogGroupName: !Ref BrokerLogs
|
|
145
|
+
FilterPattern: '{ $.event = "cli_login_start_ceiling" }'
|
|
146
|
+
MetricTransformations:
|
|
147
|
+
- MetricNamespace: cli-login
|
|
148
|
+
MetricName: StartCeilingReached
|
|
149
|
+
MetricValue: "1"
|
|
150
|
+
DefaultValue: 0
|
|
151
|
+
|
|
152
|
+
StartCeilingAlarm:
|
|
153
|
+
Type: AWS::CloudWatch::Alarm
|
|
154
|
+
Properties:
|
|
155
|
+
AlarmDescription: The cli-login broker refused starts at its ceiling across all callers (a flood from many addresses)
|
|
156
|
+
Namespace: cli-login
|
|
157
|
+
MetricName: StartCeilingReached
|
|
158
|
+
Statistic: Sum
|
|
159
|
+
Period: 300
|
|
160
|
+
EvaluationPeriods: 1
|
|
161
|
+
Threshold: 0
|
|
162
|
+
ComparisonOperator: GreaterThanThreshold
|
|
163
|
+
TreatMissingData: notBreaching
|
|
164
|
+
AlarmActions: !If [HasAlarmTopic, [!Ref AlarmTopicArn], !Ref "AWS::NoValue"]
|
|
165
|
+
|
|
141
166
|
ThrottledAlarm:
|
|
142
167
|
Type: AWS::CloudWatch::Alarm
|
|
143
168
|
Properties:
|
|
@@ -31,3 +31,10 @@ def test_the_function_url_takes_no_credentials_and_the_broker_is_bounded():
|
|
|
31
31
|
def test_the_broker_may_only_read_and_write_its_own_table():
|
|
32
32
|
assert "Resource: !GetAtt Transactions.Arn" in resource("BrokerRole")
|
|
33
33
|
assert "AttributeName: expires_at" in resource("Transactions")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_the_start_ceiling_is_logged_counted_and_alarmed():
|
|
37
|
+
assert "$.event = \"cli_login_start_ceiling\"" in resource("StartCeilingFilter")
|
|
38
|
+
assert "LogGroupName: !Ref BrokerLogs" in resource("StartCeilingFilter")
|
|
39
|
+
alarm = resource("StartCeilingAlarm")
|
|
40
|
+
assert "MetricName: StartCeilingReached" in alarm and "Namespace: cli-login" in alarm and "Dimensions" not in alarm
|
cli_login-0.3.0/project/events/2026-10-30T14-01-17.888Z__bf995622-7f63-4936-9891-4cebf7208cf5.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"event_id": "bf995622-7f63-4936-9891-4cebf7208cf5",
|
|
4
|
+
"issue_id": "cli-login-96ed2301-f038-4d76-84bb-b670184ab538",
|
|
5
|
+
"event_type": "comment_added",
|
|
6
|
+
"occurred_at": "2026-10-30T14:01:17.888Z",
|
|
7
|
+
"actor_id": "home",
|
|
8
|
+
"payload": {
|
|
9
|
+
"comment_id": "8e2eaf1f-f176-45b7-9f83-ebd9dbac93ee",
|
|
10
|
+
"comment_author": "home"
|
|
11
|
+
}
|
|
12
|
+
}
|
cli_login-0.3.0/project/events/2026-10-30T14-01-17.900Z__e9675700-922f-449e-8b9f-9779917ce373.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"event_id": "e9675700-922f-449e-8b9f-9779917ce373",
|
|
4
|
+
"issue_id": "cli-login-96ed2301-f038-4d76-84bb-b670184ab538",
|
|
5
|
+
"event_type": "state_transition",
|
|
6
|
+
"occurred_at": "2026-10-30T14:01:17.900Z",
|
|
7
|
+
"actor_id": "home",
|
|
8
|
+
"payload": {
|
|
9
|
+
"from_status": "in_progress",
|
|
10
|
+
"to_status": "closed"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"title": "Move Papyrus's CLI login into the library without breaking Mac sessions",
|
|
4
4
|
"description": "Owner: Papyrus session. Move src/papyrus_content/cli_user_auth.py and macos_keychain.py into anthus_cli_login (target, oauth, session, locks, stores/macos), behind a product target the caller supplies. Keep Keychain SERVICE 'papyrus-newsroom-oauth' and the per-site keys, so existing Mac sessions renew unchanged; keep Papyrus's behaviour and port its specs (features_backend/cli-user-auth.feature) as Gherkin here. Done when Papyrus's specs pass against the library with no behaviour change.",
|
|
5
5
|
"type": "story",
|
|
6
|
-
"status": "
|
|
6
|
+
"status": "closed",
|
|
7
7
|
"priority": 1,
|
|
8
8
|
"assignee": null,
|
|
9
9
|
"creator": null,
|
|
@@ -29,11 +29,18 @@
|
|
|
29
29
|
"text": "Library half done in #1 (merged). Kept in progress: 'without breaking Mac sessions' is only proven once the Papyrus CLI uses the library and an existing Keychain session renews through it. That Papyrus PR (Target per site, namespace papyrus-newsroom-oauth, identity_provider Google, existing state dir) is next after the stores story.",
|
|
30
30
|
"created_at": "2026-10-30T11:58:11.249739Z",
|
|
31
31
|
"comment_type": "default"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "8e2eaf1f-f176-45b7-9f83-ebd9dbac93ee",
|
|
35
|
+
"author": "home",
|
|
36
|
+
"text": "Done. Papyrus adopted cli-login==0.1.0 in AnthusAI/Papyrus#885 (merged to develop). Mac sessions: a scenario seeds an earlier-format Keychain session under papyrus-newsroom-oauth with the unchanged per-site key, and it renews through the library. Live: papyrus doctor on the M1 renewed Ryan's existing shipped.anth.us Keychain session through the library.",
|
|
37
|
+
"created_at": "2026-10-30T14:01:17.888119Z",
|
|
38
|
+
"comment_type": "default"
|
|
32
39
|
}
|
|
33
40
|
],
|
|
34
41
|
"created_at": "2026-10-30T10:52:21.309520Z",
|
|
35
|
-
"updated_at": "2026-10-
|
|
36
|
-
"closed_at":
|
|
42
|
+
"updated_at": "2026-10-30T14:01:17.900618Z",
|
|
43
|
+
"closed_at": "2026-10-30T14:01:17.900340Z",
|
|
37
44
|
"right_now_summary": null,
|
|
38
45
|
"right_now_updated_at": null,
|
|
39
46
|
"custom": {}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cli-login"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "One CLI login for every Anthus product: PKCE sign-in, remote approval, secure storage and renewal."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -104,6 +104,10 @@ class Broker:
|
|
|
104
104
|
def start(self, body: dict[str, Any], caller: str) -> tuple[int, dict[str, Any]]:
|
|
105
105
|
if not throttle.allowed(self.store, "start", caller, throttle.START, self.now()):
|
|
106
106
|
return 429, {"error": "too_many_requests", "retry_after": throttle.START.window_seconds}
|
|
107
|
+
if not throttle.allowed(self.store, "start", throttle.EVERYONE, throttle.ALL_STARTS, self.now()):
|
|
108
|
+
# One line the template's metric filter counts, so a flood raises the broker's alarm.
|
|
109
|
+
print(json.dumps({"event": "cli_login_start_ceiling", "limit": throttle.ALL_STARTS.calls}))
|
|
110
|
+
return 429, {"error": "too_many_requests", "retry_after": throttle.ALL_STARTS.window_seconds}
|
|
107
111
|
try:
|
|
108
112
|
if body.get("protocol") != PROTOCOL_VERSION:
|
|
109
113
|
return 400, {"error": "unsupported_protocol", "protocol": PROTOCOL_VERSION}
|
|
@@ -180,7 +184,11 @@ class Broker:
|
|
|
180
184
|
taken = self.store.take(transaction)
|
|
181
185
|
if taken is None:
|
|
182
186
|
return 400, {"error": "expired_token", "protocol": PROTOCOL_VERSION}
|
|
183
|
-
|
|
187
|
+
answer = {"protocol": PROTOCOL_VERSION, "code": taken["code"], "redirect_uri": self.config.callback_uri}
|
|
188
|
+
for field, name in (("oidc_nonce", "nonce"), ("enrollment", "enrollment")):
|
|
189
|
+
if taken.get(field):
|
|
190
|
+
answer[name] = taken[field]
|
|
191
|
+
return 200, answer
|
|
184
192
|
if item["state"] == DENIED:
|
|
185
193
|
return 400, {"error": "access_denied", "protocol": PROTOCOL_VERSION}
|
|
186
194
|
self.store.transition(transaction, [PENDING, BOUND], {"last_poll_at": now}, expect={"last_poll_at": last})
|
|
@@ -214,10 +222,21 @@ class Broker:
|
|
|
214
222
|
}
|
|
215
223
|
|
|
216
224
|
def bind(self, event: dict[str, Any]) -> dict[str, Any]:
|
|
225
|
+
"""Pending to bound, once. A product may also set the OpenID nonce its sign-in uses (for example a central
|
|
226
|
+
identity challenge's nonce) and an opaque ``enrollment`` string, both handed to the CLI with the code."""
|
|
217
227
|
nonce_hash = event.get("nonce_hash")
|
|
218
228
|
if self._live(event.get("transaction"), [PENDING]) is None or not HEX64.fullmatch(str(nonce_hash or "")):
|
|
219
229
|
return {"ok": False, "error": "unavailable"}
|
|
220
|
-
|
|
230
|
+
changes = {"state": BOUND, "nonce_hash": nonce_hash}
|
|
231
|
+
if "nonce" in event:
|
|
232
|
+
if not NONCE.fullmatch(str(event["nonce"])):
|
|
233
|
+
return {"ok": False, "error": "unavailable"}
|
|
234
|
+
changes["oidc_nonce"] = event["nonce"]
|
|
235
|
+
if "enrollment" in event:
|
|
236
|
+
if not isinstance(event["enrollment"], str) or not event["enrollment"]:
|
|
237
|
+
return {"ok": False, "error": "unavailable"}
|
|
238
|
+
changes["enrollment"] = event["enrollment"]
|
|
239
|
+
bound = self.store.transition(event["transaction"], [PENDING], changes)
|
|
221
240
|
return {"ok": True} if bound else {"ok": False, "error": "unavailable"}
|
|
222
241
|
|
|
223
242
|
def complete(self, event: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import json
|
|
3
3
|
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
4
6
|
from cli_login.broker import throttle
|
|
5
7
|
from cli_login.broker.handler import Broker, BrokerConfig, matching_code, sha256_hex
|
|
6
8
|
from cli_login.broker.transactions import MemoryTransactions
|
|
@@ -282,3 +284,51 @@ def test_the_config_requires_its_client_targets_and_https_pages():
|
|
|
282
284
|
"BROKER_CALLBACK_URI": "https://a.example.test/cli/callback",
|
|
283
285
|
})
|
|
284
286
|
assert config.targets == ("https://a.example.test",)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_starts_across_all_callers_have_one_ceiling_and_say_so_in_the_log(capsys):
|
|
290
|
+
b = broker()
|
|
291
|
+
callers = [f"198.51.100.{n}" for n in range(throttle.ALL_STARTS.calls // throttle.START.calls + 2)]
|
|
292
|
+
statuses = [b.start(start_body(), caller)[0] for caller in callers for _ in range(throttle.START.calls)]
|
|
293
|
+
assert statuses.count(200) == throttle.ALL_STARTS.calls
|
|
294
|
+
assert b.start(start_body(), "203.0.113.200") == (429, {"error": "too_many_requests", "retry_after": 60})
|
|
295
|
+
assert '"event": "cli_login_start_ceiling"' in capsys.readouterr().out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_the_ceiling_resets_with_the_window():
|
|
299
|
+
clock = Clock()
|
|
300
|
+
b = broker(clock)
|
|
301
|
+
for n in range(throttle.ALL_STARTS.calls):
|
|
302
|
+
b.start(start_body(), f"caller-{n}")
|
|
303
|
+
assert b.start(start_body(), "caller-new")[0] == 429
|
|
304
|
+
clock.now += 60
|
|
305
|
+
assert b.start(start_body(), "caller-new")[0] == 200
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_a_product_nonce_and_enrollment_set_at_bind_come_back_once_with_the_code():
|
|
309
|
+
clock = Clock()
|
|
310
|
+
b = broker(clock)
|
|
311
|
+
transaction = started(b)
|
|
312
|
+
assert b.bind({"transaction": transaction, "nonce_hash": sha256_hex("n"), "nonce": "challenge-nonce-0001",
|
|
313
|
+
"enrollment": "sealed-proof"}) == {"ok": True}
|
|
314
|
+
assert b.complete({"transaction": transaction, "nonce": "n", "code": "code"}) == {"ok": True}
|
|
315
|
+
clock.now += 5
|
|
316
|
+
status, answer = b.poll({"transaction": transaction, "poll_secret": SECRET}, "ip")
|
|
317
|
+
assert status == 200 and answer["nonce"] == "challenge-nonce-0001" and answer["enrollment"] == "sealed-proof"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_without_a_product_nonce_the_poll_answer_is_unchanged():
|
|
321
|
+
clock = Clock()
|
|
322
|
+
b = broker(clock)
|
|
323
|
+
transaction = started(b)
|
|
324
|
+
approve(b, transaction)
|
|
325
|
+
clock.now += 5
|
|
326
|
+
assert set(b.poll({"transaction": transaction, "poll_secret": SECRET}, "ip")[1]) == {"protocol", "code", "redirect_uri"}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@pytest.mark.parametrize("extra", [{"nonce": "short"}, {"nonce": 5}, {"enrollment": ""}, {"enrollment": ["x"]}])
|
|
330
|
+
def test_a_malformed_product_nonce_or_enrollment_is_refused_and_nothing_is_bound(extra):
|
|
331
|
+
b = broker()
|
|
332
|
+
transaction = started(b)
|
|
333
|
+
assert b.bind({"transaction": transaction, "nonce_hash": sha256_hex("n"), **extra}) == {"ok": False, "error": "unavailable"}
|
|
334
|
+
assert b.describe({"transaction": transaction})["ok"] is True
|
|
@@ -89,12 +89,18 @@ class ApprovalPages:
|
|
|
89
89
|
privileges: str,
|
|
90
90
|
scope: str = "openid email profile",
|
|
91
91
|
identity_provider: str | None = None,
|
|
92
|
+
prepare: Callable[[str, Mapping], tuple[str, str]] | None = None,
|
|
92
93
|
token=secrets.token_urlsafe,
|
|
93
94
|
):
|
|
95
|
+
"""``prepare(transaction, described)``, when given, runs on Approve before the bind and returns the OpenID
|
|
96
|
+
nonce the sign-in must use and an opaque ``enrollment`` string for the product's own enrollment step (for
|
|
97
|
+
example a central identity challenge's nonce and its sealed proof). The broker hands both to the CLI with
|
|
98
|
+
the code; a CLI accepts the nonce only when it asked to enroll."""
|
|
94
99
|
self.invoke, self.authorize_endpoint, self.product = (lambda payload: invoke({**payload, "cli_login": PRIVATE_MARKER})), authorize_endpoint, product
|
|
95
100
|
parts = urlsplit(authorize_endpoint)
|
|
96
101
|
self.form_action = f"'self' {parts.scheme}://{parts.netloc}"
|
|
97
102
|
self.privileges, self.scope, self.identity_provider, self.token = privileges, scope, identity_provider, token
|
|
103
|
+
self.prepare = prepare
|
|
98
104
|
|
|
99
105
|
def approve(self, method: str, query: Mapping[str, str], form: Mapping[str, str], cookies: Mapping[str, str]) -> Page:
|
|
100
106
|
if method == "GET":
|
|
@@ -114,7 +120,15 @@ class ApprovalPages:
|
|
|
114
120
|
if not described.get("ok"):
|
|
115
121
|
return _gone()
|
|
116
122
|
nonce = self.token(32)
|
|
117
|
-
|
|
123
|
+
bind = {"operation": "bind", "transaction": transaction, "nonce_hash": hashlib.sha256(nonce.encode()).hexdigest()}
|
|
124
|
+
oidc_nonce = described["nonce"]
|
|
125
|
+
if self.prepare is not None:
|
|
126
|
+
try:
|
|
127
|
+
oidc_nonce, enrollment = self.prepare(transaction, described)
|
|
128
|
+
except Exception: # noqa: BLE001 - the product's enrollment service is unavailable: nothing is bound
|
|
129
|
+
return _message(503, "Sign-in is unavailable", "Try the approval link again in a moment.")
|
|
130
|
+
bind |= {"nonce": oidc_nonce, "enrollment": enrollment}
|
|
131
|
+
if not self.invoke(bind).get("ok"):
|
|
118
132
|
return _gone()
|
|
119
133
|
query_string = {
|
|
120
134
|
"client_id": described["client_id"],
|
|
@@ -122,7 +136,7 @@ class ApprovalPages:
|
|
|
122
136
|
"redirect_uri": described["redirect_uri"],
|
|
123
137
|
"scope": self.scope,
|
|
124
138
|
"state": f"{transaction}.{nonce}",
|
|
125
|
-
"nonce":
|
|
139
|
+
"nonce": oidc_nonce,
|
|
126
140
|
"code_challenge": described["code_challenge"],
|
|
127
141
|
"code_challenge_method": "S256",
|
|
128
142
|
}
|
|
@@ -133,3 +133,42 @@ def test_every_private_call_carries_the_marker():
|
|
|
133
133
|
privileges="Act as you.")
|
|
134
134
|
pages.approve("GET", {"t": "x"}, {}, {})
|
|
135
135
|
assert calls and all(call["cli_login"] == "private-v1" for call in calls)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_a_prepare_hook_sets_the_sign_in_nonce_and_the_enrollment():
|
|
139
|
+
broker, _, transaction = world()
|
|
140
|
+
seen = []
|
|
141
|
+
|
|
142
|
+
def prepare(t, described):
|
|
143
|
+
seen.append((t, described["client_id"]))
|
|
144
|
+
return "challenge-nonce-0001", "sealed-proof"
|
|
145
|
+
|
|
146
|
+
pages = ApprovalPages(broker.handle, authorize_endpoint=AUTHORIZE, product="Example", privileges="Act as you.",
|
|
147
|
+
prepare=prepare, token=counter())
|
|
148
|
+
page = pages.approve("GET", {"t": transaction}, {}, {})
|
|
149
|
+
csrf = cookie(page, CSRF_COOKIE)
|
|
150
|
+
approved = pages.approve("POST", {}, {"t": transaction, "csrf": csrf, "action": "approve", "confirm": "yes"}, {CSRF_COOKIE: csrf})
|
|
151
|
+
query = {key: value[0] for key, value in parse_qs(urlsplit(header(approved, "Location")).query).items()}
|
|
152
|
+
assert seen == [(transaction, "client-1")] and query["nonce"] == "challenge-nonce-0001"
|
|
153
|
+
item = broker.store.get(transaction)
|
|
154
|
+
assert item["oidc_nonce"] == "challenge-nonce-0001" and item["enrollment"] == "sealed-proof"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_when_prepare_fails_nothing_is_bound_and_the_link_still_works():
|
|
158
|
+
broker, _, transaction = world()
|
|
159
|
+
|
|
160
|
+
def prepare(t, described):
|
|
161
|
+
raise RuntimeError("central identity service unavailable")
|
|
162
|
+
|
|
163
|
+
pages = ApprovalPages(broker.handle, authorize_endpoint=AUTHORIZE, product="Example", privileges="Act as you.",
|
|
164
|
+
prepare=prepare, token=counter())
|
|
165
|
+
page = pages.approve("GET", {"t": transaction}, {}, {})
|
|
166
|
+
csrf = cookie(page, CSRF_COOKIE)
|
|
167
|
+
failed = pages.approve("POST", {}, {"t": transaction, "csrf": csrf, "action": "approve", "confirm": "yes"}, {CSRF_COOKIE: csrf})
|
|
168
|
+
assert failed.status == 503 and "central identity" not in failed.body
|
|
169
|
+
assert broker.describe({"transaction": transaction})["ok"] is True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def counter():
|
|
173
|
+
tokens = iter(f"tok{n:02d}" for n in range(99))
|
|
174
|
+
return lambda _n: next(tokens)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Rate limits for the broker's public routes.
|
|
2
2
|
|
|
3
3
|
The broker's ``/start`` and ``/poll`` take calls from anyone who can reach the function URL, so each caller (its
|
|
4
|
-
source address) gets a fixed number of calls per window.
|
|
5
|
-
|
|
4
|
+
source address, or its IPv6 /64) gets a fixed number of calls per window. Starts also have one ceiling across all
|
|
5
|
+
callers, for a flood spread over many addresses; real use is a handful of sign-ins a day. A CLI that polls one
|
|
6
|
+
transaction faster than its interval is told ``slow_down`` separately, by the poll itself.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from __future__ import annotations
|
|
@@ -18,6 +19,8 @@ class Limit:
|
|
|
18
19
|
|
|
19
20
|
START = Limit(calls=10, window_seconds=60)
|
|
20
21
|
POLL = Limit(calls=120, window_seconds=60)
|
|
22
|
+
ALL_STARTS = Limit(calls=100, window_seconds=60)
|
|
23
|
+
EVERYONE = "*" # the caller name the ceiling across all callers is counted under; never a real address
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
def allowed(store, route: str, caller: str, limit: Limit, now: int) -> bool:
|
|
@@ -101,6 +101,7 @@ def remote_login(
|
|
|
101
101
|
host: str | None = None,
|
|
102
102
|
note: str | None = None,
|
|
103
103
|
expected_email: str | None = None,
|
|
104
|
+
enroll: Callable[[Mapping[str, Any], str, str | None], Any] | None = None,
|
|
104
105
|
token=secrets.token_urlsafe,
|
|
105
106
|
):
|
|
106
107
|
"""Sign in through the target's broker and return what ``store_token_set`` returns.
|
|
@@ -113,6 +114,12 @@ def remote_login(
|
|
|
113
114
|
Whoever approves first owns the session, and the approval URL and code are often relayed through chat. Pass
|
|
114
115
|
``expected_email`` to refuse, and store nothing, when the person who approved is someone else. The signed-in
|
|
115
116
|
identity is always printed.
|
|
117
|
+
|
|
118
|
+
``enroll(tokens, transaction, enrollment)``, when given, runs after the exchange and the identity checks and
|
|
119
|
+
before anything is stored; if it raises, nothing is stored. It is for a product whose sign-in needs its own
|
|
120
|
+
enrollment of each login (the Cyclotron lab enrolls each Cognito login at Prospectus). Only then does the CLI
|
|
121
|
+
accept an OpenID nonce chosen by the product's approval page, which the broker hands over with the code;
|
|
122
|
+
without ``enroll`` the CLI checks its own nonce and nothing else.
|
|
116
123
|
"""
|
|
117
124
|
broker = getattr(target, "broker_endpoint", None)
|
|
118
125
|
if not broker:
|
|
@@ -198,11 +205,15 @@ def remote_login(
|
|
|
198
205
|
if status != 200:
|
|
199
206
|
raise RemoteLoginError(str(tokens.get("error") or "exchange_refused"), "The sign-in provider refused the code; start the sign-in again.")
|
|
200
207
|
claims = _claims(tokens.get("id_token"))
|
|
201
|
-
if
|
|
208
|
+
expected_nonce = answer.get("nonce") if enroll is not None and isinstance(answer.get("nonce"), str) else nonce
|
|
209
|
+
if claims.get("nonce") != expected_nonce:
|
|
202
210
|
raise RemoteLoginError("nonce_mismatch", "The sign-in answered tokens for a different request; nothing was saved.")
|
|
203
211
|
email = claims.get("email") if isinstance(claims.get("email"), str) else None
|
|
204
212
|
if expected_email is not None and (email or "").casefold() != expected_email.casefold():
|
|
205
213
|
raise RemoteLoginError("unexpected_identity", "Someone other than the expected person approved this sign-in; nothing was saved.")
|
|
214
|
+
if enroll is not None:
|
|
215
|
+
enrollment = answer.get("enrollment") if isinstance(answer.get("enrollment"), str) else None
|
|
216
|
+
enroll(tokens, transaction, enrollment)
|
|
206
217
|
record = store_token_set(tokens)
|
|
207
218
|
out(f"Signed in as {email or claims.get('sub', 'an unknown identity')}.")
|
|
208
219
|
return record
|
|
@@ -32,7 +32,8 @@ def jwt(claims):
|
|
|
32
32
|
class World:
|
|
33
33
|
"""A real broker in-process, an approver who acts on the Nth poll, and a fake token endpoint."""
|
|
34
34
|
|
|
35
|
-
def __init__(self, *, approve_on_poll=2, deny=False):
|
|
35
|
+
def __init__(self, *, approve_on_poll=2, deny=False, product_nonce=None):
|
|
36
|
+
self.product_nonce = product_nonce
|
|
36
37
|
self.now = 1000.0
|
|
37
38
|
self.broker = Broker(
|
|
38
39
|
MemoryTransactions(),
|
|
@@ -51,12 +52,13 @@ class World:
|
|
|
51
52
|
|
|
52
53
|
def approver(self):
|
|
53
54
|
described = self.broker.describe({"transaction": self.transaction})
|
|
54
|
-
self.issued_nonce = described["nonce"]
|
|
55
|
+
self.issued_nonce = self.product_nonce or described["nonce"]
|
|
55
56
|
self.challenge = described["code_challenge"]
|
|
56
57
|
if self.deny:
|
|
57
58
|
self.broker.deny({"transaction": self.transaction})
|
|
58
59
|
return
|
|
59
|
-
|
|
60
|
+
extra = {"nonce": self.product_nonce, "enrollment": "sealed-proof"} if self.product_nonce else {}
|
|
61
|
+
self.broker.bind({"transaction": self.transaction, "nonce_hash": sha256_hex("browser"), **extra})
|
|
60
62
|
self.broker.complete({"transaction": self.transaction, "nonce": "browser", "code": "auth-code"})
|
|
61
63
|
|
|
62
64
|
def http(self, method, url, *, json_body=None, form=None, timeout=30):
|
|
@@ -314,3 +316,41 @@ def test_a_loopback_target_still_checks_the_brokers_callback_and_approval_origin
|
|
|
314
316
|
with pytest.raises(RemoteLoginError) as raised:
|
|
315
317
|
world.login(target=target)
|
|
316
318
|
assert raised.value.code == "invalid_broker_answer" and world.polls == 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def enrolling(world, calls, *, fail=False):
|
|
322
|
+
def enroll(tokens, transaction, enrollment):
|
|
323
|
+
calls.append((tokens["refresh_token"], transaction, enrollment))
|
|
324
|
+
if fail:
|
|
325
|
+
raise RemoteLoginError("enrollment_refused", "The product refused to enroll this sign-in.")
|
|
326
|
+
return enroll
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_an_enrolling_sign_in_accepts_the_product_nonce_and_enrolls_before_storing():
|
|
330
|
+
world, stored, calls = World(product_nonce="challenge-nonce-0001"), [], []
|
|
331
|
+
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock, sleep=world.sleep,
|
|
332
|
+
host="cloud-box", enroll=enrolling(world, calls))
|
|
333
|
+
assert calls == [("refresh", world.transaction, "sealed-proof")] and len(stored) == 1
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_without_enroll_a_product_nonce_is_never_accepted():
|
|
337
|
+
world, stored = World(product_nonce="challenge-nonce-0001"), []
|
|
338
|
+
with pytest.raises(RemoteLoginError) as raised:
|
|
339
|
+
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock,
|
|
340
|
+
sleep=world.sleep, host="cloud-box")
|
|
341
|
+
assert raised.value.code == "nonce_mismatch" and stored == []
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_an_enrolling_sign_in_without_a_product_nonce_keeps_its_own_nonce_check():
|
|
345
|
+
world, stored, calls = World(), [], []
|
|
346
|
+
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock, sleep=world.sleep,
|
|
347
|
+
host="cloud-box", enroll=enrolling(world, calls))
|
|
348
|
+
assert calls[0][2] is None and len(stored) == 1
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_a_refused_enrollment_stores_nothing():
|
|
352
|
+
world, stored, calls = World(product_nonce="challenge-nonce-0001"), [], []
|
|
353
|
+
with pytest.raises(RemoteLoginError) as raised:
|
|
354
|
+
remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock,
|
|
355
|
+
sleep=world.sleep, host="cloud-box", enroll=enrolling(world, calls, fail=True))
|
|
356
|
+
assert raised.value.code == "enrollment_refused" and stored == []
|
|
@@ -29,7 +29,16 @@ class Session:
|
|
|
29
29
|
state_dir=None,
|
|
30
30
|
login_command: str = "login",
|
|
31
31
|
identity_provider: str | None = None,
|
|
32
|
+
api_token: str = "id",
|
|
33
|
+
authorization_scheme: str | None = None,
|
|
32
34
|
):
|
|
35
|
+
"""``api_token`` names the token ``headers()`` hands to the API: ``"id"`` (Papyrus's AppSync) or ``"access"``
|
|
36
|
+
(an API that checks Cognito access tokens, such as the Cyclotron lab). ``authorization_scheme`` prefixes it,
|
|
37
|
+
for example ``"Bearer"``. The access token is only ever held in memory, never stored."""
|
|
38
|
+
if api_token not in ("id", "access"):
|
|
39
|
+
raise ValueError('api_token is "id" or "access"')
|
|
40
|
+
if authorization_scheme not in (None, "Bearer"):
|
|
41
|
+
raise ValueError('authorization_scheme is None or "Bearer"')
|
|
33
42
|
self.target = target
|
|
34
43
|
self.store = store if store is not None else open_store(target)
|
|
35
44
|
self.provider = provider if provider is not None else HTTPProvider()
|
|
@@ -38,6 +47,8 @@ class Session:
|
|
|
38
47
|
self.state_dir = state_dir if state_dir is not None else default_state_dir(target.namespace)
|
|
39
48
|
self.login_command = login_command
|
|
40
49
|
self.identity_provider = identity_provider
|
|
50
|
+
self.api_token = api_token
|
|
51
|
+
self.authorization_scheme = authorization_scheme
|
|
41
52
|
self._token = None
|
|
42
53
|
self._expires = 0.0
|
|
43
54
|
self._subject = None
|
|
@@ -102,6 +113,17 @@ class Session:
|
|
|
102
113
|
):
|
|
103
114
|
raise ValueError()
|
|
104
115
|
expires = min(float(claims["exp"]), self.clock() + float(response["expires_in"]))
|
|
116
|
+
if self.api_token == "access":
|
|
117
|
+
value = response["access_token"]
|
|
118
|
+
access = json.loads(base64.urlsafe_b64decode(value.split(".")[1] + "==="))
|
|
119
|
+
if (
|
|
120
|
+
access["iss"] != expected.issuer
|
|
121
|
+
or access["client_id"] != expected.client_id
|
|
122
|
+
or access["token_use"] != "access"
|
|
123
|
+
or access["sub"] != claims["sub"]
|
|
124
|
+
):
|
|
125
|
+
raise ValueError()
|
|
126
|
+
expires = min(expires, float(access["exp"]))
|
|
105
127
|
if (
|
|
106
128
|
not math.isfinite(expires)
|
|
107
129
|
or not math.isfinite(float(response["expires_in"]))
|
|
@@ -172,7 +194,8 @@ class Session:
|
|
|
172
194
|
self._token = None
|
|
173
195
|
raise OAuthError(error.code, transient=error.transient, login_command=self.login_command) from None
|
|
174
196
|
self.store_token_set(response, previous=record)
|
|
175
|
-
|
|
197
|
+
token = self._token if self.authorization_scheme is None else f"{self.authorization_scheme} {self._token}"
|
|
198
|
+
return {"content-type": "application/json", "Authorization": token}
|
|
176
199
|
|
|
177
200
|
def status(self) -> dict:
|
|
178
201
|
try:
|
|
@@ -223,3 +223,63 @@ def test_a_corrupt_stored_record_is_an_error_with_guidance(world):
|
|
|
223
223
|
store.data[target.key] = "not json"
|
|
224
224
|
with pytest.raises(AuthError, match="tool login"):
|
|
225
225
|
new().headers(target.api_endpoint)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_by_default_headers_hand_out_the_bare_id_token_exactly_as_in_0_1_0(world):
|
|
229
|
+
target, _clock, _store, _provider, _seen, new = world
|
|
230
|
+
session = new()
|
|
231
|
+
session.login()
|
|
232
|
+
headers = session.headers(target.api_endpoint)
|
|
233
|
+
assert headers == {"content-type": "application/json", "Authorization": session._token}
|
|
234
|
+
assert json.loads(__import__("base64").urlsafe_b64decode(headers["Authorization"].split(".")[1] + "==="))["token_use"] == "id"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_an_access_token_api_gets_the_access_token_with_its_scheme(world):
|
|
238
|
+
target, _clock, _store, _provider, _seen, new = world
|
|
239
|
+
session = new(api_token="access", authorization_scheme="Bearer")
|
|
240
|
+
session.login()
|
|
241
|
+
scheme, _, token = session.headers(target.api_endpoint)["Authorization"].partition(" ")
|
|
242
|
+
claims = json.loads(__import__("base64").urlsafe_b64decode(token.split(".")[1] + "==="))
|
|
243
|
+
assert scheme == "Bearer" and claims["token_use"] == "access" and claims["client_id"] == target.client_id
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_the_access_token_is_never_stored(world):
|
|
247
|
+
target, _clock, store, _provider, _seen, new = world
|
|
248
|
+
session = new(api_token="access", authorization_scheme="Bearer")
|
|
249
|
+
session.login()
|
|
250
|
+
record = json.loads(store.data[target.key])
|
|
251
|
+
assert set(record) == {"subject", "label", "refresh_token", "created_at"}
|
|
252
|
+
assert session._token not in store.data[target.key]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@pytest.mark.parametrize("change", [{"client_id": "other"}, {"token_use": "id"}, {"issuer": "https://elsewhere.example.test/p"},
|
|
256
|
+
{"subject": "someone-else"}])
|
|
257
|
+
def test_a_mismatched_access_token_is_refused_and_nothing_is_saved(world, change):
|
|
258
|
+
from .testing import make_access_token
|
|
259
|
+
|
|
260
|
+
_target, clock, store, _provider, _seen, new = world
|
|
261
|
+
session = new(api_token="access")
|
|
262
|
+
response = {"id_token": make_id_token(clock()), "access_token": make_access_token(clock(), **change),
|
|
263
|
+
"refresh_token": "r", "expires_in": 3600}
|
|
264
|
+
with pytest.raises(AuthError):
|
|
265
|
+
session.store_token_set(response)
|
|
266
|
+
assert store.data == {}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_a_short_lived_access_token_is_renewed_before_it_lapses(world):
|
|
270
|
+
from .testing import make_access_token
|
|
271
|
+
|
|
272
|
+
_target, clock, _store, _provider, _seen, new = world
|
|
273
|
+
session = new(api_token="access")
|
|
274
|
+
session.store_token_set({"id_token": make_id_token(clock()), "access_token": make_access_token(clock(), lifetime=300),
|
|
275
|
+
"refresh_token": "r", "expires_in": 3600})
|
|
276
|
+
assert session._expires == clock() + 300
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_a_session_names_only_known_token_choices():
|
|
280
|
+
from .testing import make_target
|
|
281
|
+
|
|
282
|
+
with pytest.raises(ValueError):
|
|
283
|
+
Session(make_target(), store=FakeStore(), api_token="refresh")
|
|
284
|
+
with pytest.raises(ValueError):
|
|
285
|
+
Session(make_target(), store=FakeStore(), authorization_scheme="Basic")
|
|
@@ -43,6 +43,15 @@ def make_id_token(now, *, subject="person-1", issuer=ISSUER, audience=CLIENT_ID,
|
|
|
43
43
|
return encode({"alg": "RS256"}) + "." + encode(claims) + ".fixture-signature"
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def make_access_token(now, *, subject="person-1", issuer=ISSUER, client_id=CLIENT_ID, token_use="access", lifetime=3600):
|
|
47
|
+
claims = {"iss": issuer, "client_id": client_id, "sub": subject, "token_use": token_use, "exp": now + lifetime}
|
|
48
|
+
|
|
49
|
+
def encode(value):
|
|
50
|
+
return base64.urlsafe_b64encode(json.dumps(value).encode()).decode().rstrip("=")
|
|
51
|
+
|
|
52
|
+
return encode({"alg": "RS256"}) + "." + encode(claims) + ".fixture-signature"
|
|
53
|
+
|
|
54
|
+
|
|
46
55
|
class FakeClock:
|
|
47
56
|
def __init__(self, now=1000.0):
|
|
48
57
|
self.now = now
|
|
@@ -97,6 +106,7 @@ class FakeProvider:
|
|
|
97
106
|
raise self.failure
|
|
98
107
|
return {
|
|
99
108
|
"id_token": make_id_token(self.clock(), subject=self.subject, issuer=self.issuer),
|
|
109
|
+
"access_token": make_access_token(self.clock(), subject=self.subject, issuer=self.issuer),
|
|
100
110
|
"refresh_token": "refresh-secret-" + str(len(self.calls)),
|
|
101
111
|
"expires_in": 3600,
|
|
102
112
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|