cli-login 0.1.0__tar.gz → 0.2.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.2.0}/PKG-INFO +1 -1
- {cli_login-0.1.0 → cli_login-0.2.0}/docs/protocol.md +3 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/infra/broker.yaml +25 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/infra/broker_template_test.py +7 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/pyproject.toml +1 -1
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/__init__.py +1 -1
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/handler.py +4 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/handler_test.py +19 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/throttle.py +6 -3
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/session.py +24 -1
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/session_test.py +60 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/testing.py +10 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/.claude/settings.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/.cursorignore +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/.github/workflows/release.yml +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/.github/workflows/tests.yml +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/.gitignore +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/.kanbus.yml +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/.vscode/settings.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/AGENTS.md +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/CONTRIBUTING_AGENT.md +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/CONTRIBUTING_AGENT.template.md +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/LICENSE +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/README.md +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/docs/design.md +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/environment.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/locks.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/login_session.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/oauth_provider.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/remote_sign_in.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/locks_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/login_session_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/oauth_provider_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/remote_sign_in_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/store_common_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_agent_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_kernel_keyring_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_macos_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_secret_service_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_selection_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_windows_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/target_steps.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_agent.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_agent_windows.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_kernel_keyring.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_macos.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_secret_service.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_selection.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_windows.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/features/target.feature +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/AGENTS.md +0 -0
- {cli_login-0.1.0 → cli_login-0.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.0}/project/events/AGENTS.md +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/events/DO_NOT_EDIT +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/AGENTS.md +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/DO_NOT_EDIT +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-04de9a1b-0c3e-4589-b9b5-ae30cd3cdd18.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-1b4a091f-2bfc-405c-be6e-b787b4bb4696.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-31750ff1-8d69-4b03-b953-82da220c1a9f.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-361d5c51-7951-4e46-9c57-a64cb6f8058f.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-85e67311-6ca7-4c57-9e44-4d414ebc618f.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-96ed2301-f038-4d76-84bb-b670184ab538.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-9a02d68d-d3ff-4dfd-a319-2c411f05fddd.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-cccc972a-7df2-4795-9f9b-d8805dfc142e.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-ff76cbbc-d47b-49d5-91e1-4aa2ae15e99f.json +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/wiki/index.md +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/project/wiki/whats-next.md +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/scripts/build_broker.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/__init___test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/__init__.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/pages.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/pages_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/transactions.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/transactions_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/concurrency_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/errors.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/errors_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/locks.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/locks_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/oauth.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/oauth_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/remote.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/remote_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/__init__.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/agent.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/agent_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/kernel_keyring.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/kernel_keyring_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/macos.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/macos_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/secret_service.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/secret_service_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/stores_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/windows.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/windows_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/target.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/target_test.py +0 -0
- {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/testing_processes.py +0 -0
|
@@ -151,3 +151,6 @@ Every page sends:
|
|
|
151
151
|
every read.
|
|
152
152
|
- The poll interval starts at 5 seconds, and each `slow_down` adds 5.
|
|
153
153
|
- The rate limits per caller are 10 starts and 120 polls a minute.
|
|
154
|
+
- All callers together share a ceiling of 100 starts a minute. Past it, every start answers `429` with
|
|
155
|
+
`retry_after` until the window ends. The broker logs `cli_login_start_ceiling`, and the template turns that log
|
|
156
|
+
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
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cli-login"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.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}
|
|
@@ -282,3 +282,22 @@ def test_the_config_requires_its_client_targets_and_https_pages():
|
|
|
282
282
|
"BROKER_CALLBACK_URI": "https://a.example.test/cli/callback",
|
|
283
283
|
})
|
|
284
284
|
assert config.targets == ("https://a.example.test",)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_starts_across_all_callers_have_one_ceiling_and_say_so_in_the_log(capsys):
|
|
288
|
+
b = broker()
|
|
289
|
+
callers = [f"198.51.100.{n}" for n in range(throttle.ALL_STARTS.calls // throttle.START.calls + 2)]
|
|
290
|
+
statuses = [b.start(start_body(), caller)[0] for caller in callers for _ in range(throttle.START.calls)]
|
|
291
|
+
assert statuses.count(200) == throttle.ALL_STARTS.calls
|
|
292
|
+
assert b.start(start_body(), "203.0.113.200") == (429, {"error": "too_many_requests", "retry_after": 60})
|
|
293
|
+
assert '"event": "cli_login_start_ceiling"' in capsys.readouterr().out
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_the_ceiling_resets_with_the_window():
|
|
297
|
+
clock = Clock()
|
|
298
|
+
b = broker(clock)
|
|
299
|
+
for n in range(throttle.ALL_STARTS.calls):
|
|
300
|
+
b.start(start_body(), f"caller-{n}")
|
|
301
|
+
assert b.start(start_body(), "caller-new")[0] == 429
|
|
302
|
+
clock.now += 60
|
|
303
|
+
assert b.start(start_body(), "caller-new")[0] == 200
|
|
@@ -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:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|