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.
Files changed (127) hide show
  1. {cli_login-0.1.0 → cli_login-0.2.0}/PKG-INFO +1 -1
  2. {cli_login-0.1.0 → cli_login-0.2.0}/docs/protocol.md +3 -0
  3. {cli_login-0.1.0 → cli_login-0.2.0}/infra/broker.yaml +25 -0
  4. {cli_login-0.1.0 → cli_login-0.2.0}/infra/broker_template_test.py +7 -0
  5. {cli_login-0.1.0 → cli_login-0.2.0}/pyproject.toml +1 -1
  6. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/__init__.py +1 -1
  7. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/handler.py +4 -0
  8. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/handler_test.py +19 -0
  9. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/throttle.py +6 -3
  10. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/session.py +24 -1
  11. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/session_test.py +60 -0
  12. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/testing.py +10 -0
  13. {cli_login-0.1.0 → cli_login-0.2.0}/.claude/settings.json +0 -0
  14. {cli_login-0.1.0 → cli_login-0.2.0}/.cursorignore +0 -0
  15. {cli_login-0.1.0 → cli_login-0.2.0}/.github/workflows/release.yml +0 -0
  16. {cli_login-0.1.0 → cli_login-0.2.0}/.github/workflows/tests.yml +0 -0
  17. {cli_login-0.1.0 → cli_login-0.2.0}/.gitignore +0 -0
  18. {cli_login-0.1.0 → cli_login-0.2.0}/.kanbus.yml +0 -0
  19. {cli_login-0.1.0 → cli_login-0.2.0}/.vscode/settings.json +0 -0
  20. {cli_login-0.1.0 → cli_login-0.2.0}/AGENTS.md +0 -0
  21. {cli_login-0.1.0 → cli_login-0.2.0}/CONTRIBUTING_AGENT.md +0 -0
  22. {cli_login-0.1.0 → cli_login-0.2.0}/CONTRIBUTING_AGENT.template.md +0 -0
  23. {cli_login-0.1.0 → cli_login-0.2.0}/LICENSE +0 -0
  24. {cli_login-0.1.0 → cli_login-0.2.0}/README.md +0 -0
  25. {cli_login-0.1.0 → cli_login-0.2.0}/docs/design.md +0 -0
  26. {cli_login-0.1.0 → cli_login-0.2.0}/features/environment.py +0 -0
  27. {cli_login-0.1.0 → cli_login-0.2.0}/features/locks.feature +0 -0
  28. {cli_login-0.1.0 → cli_login-0.2.0}/features/login_session.feature +0 -0
  29. {cli_login-0.1.0 → cli_login-0.2.0}/features/oauth_provider.feature +0 -0
  30. {cli_login-0.1.0 → cli_login-0.2.0}/features/remote_sign_in.feature +0 -0
  31. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/locks_steps.py +0 -0
  32. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/login_session_steps.py +0 -0
  33. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/oauth_provider_steps.py +0 -0
  34. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/remote_sign_in_steps.py +0 -0
  35. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/store_common_steps.py +0 -0
  36. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_agent_steps.py +0 -0
  37. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_kernel_keyring_steps.py +0 -0
  38. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_macos_steps.py +0 -0
  39. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_secret_service_steps.py +0 -0
  40. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_selection_steps.py +0 -0
  41. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/stores_windows_steps.py +0 -0
  42. {cli_login-0.1.0 → cli_login-0.2.0}/features/steps/target_steps.py +0 -0
  43. {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_agent.feature +0 -0
  44. {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_agent_windows.feature +0 -0
  45. {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_kernel_keyring.feature +0 -0
  46. {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_macos.feature +0 -0
  47. {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_secret_service.feature +0 -0
  48. {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_selection.feature +0 -0
  49. {cli_login-0.1.0 → cli_login-0.2.0}/features/stores_windows.feature +0 -0
  50. {cli_login-0.1.0 → cli_login-0.2.0}/features/target.feature +0 -0
  51. {cli_login-0.1.0 → cli_login-0.2.0}/project/AGENTS.md +0 -0
  52. {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
  53. {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
  54. {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
  55. {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
  56. {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
  57. {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
  58. {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
  59. {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
  60. {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
  61. {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
  62. {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
  63. {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
  64. {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
  65. {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
  66. {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
  67. {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
  68. {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
  69. {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
  70. {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
  71. {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
  72. {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
  73. {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
  74. {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
  75. {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
  76. {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
  77. {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
  78. {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
  79. {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
  80. {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
  81. {cli_login-0.1.0 → cli_login-0.2.0}/project/events/AGENTS.md +0 -0
  82. {cli_login-0.1.0 → cli_login-0.2.0}/project/events/DO_NOT_EDIT +0 -0
  83. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/AGENTS.md +0 -0
  84. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/DO_NOT_EDIT +0 -0
  85. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-04de9a1b-0c3e-4589-b9b5-ae30cd3cdd18.json +0 -0
  86. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-1b4a091f-2bfc-405c-be6e-b787b4bb4696.json +0 -0
  87. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-31750ff1-8d69-4b03-b953-82da220c1a9f.json +0 -0
  88. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4.json +0 -0
  89. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-361d5c51-7951-4e46-9c57-a64cb6f8058f.json +0 -0
  90. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-85e67311-6ca7-4c57-9e44-4d414ebc618f.json +0 -0
  91. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-96ed2301-f038-4d76-84bb-b670184ab538.json +0 -0
  92. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-9a02d68d-d3ff-4dfd-a319-2c411f05fddd.json +0 -0
  93. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-cccc972a-7df2-4795-9f9b-d8805dfc142e.json +0 -0
  94. {cli_login-0.1.0 → cli_login-0.2.0}/project/issues/cli-login-ff76cbbc-d47b-49d5-91e1-4aa2ae15e99f.json +0 -0
  95. {cli_login-0.1.0 → cli_login-0.2.0}/project/wiki/index.md +0 -0
  96. {cli_login-0.1.0 → cli_login-0.2.0}/project/wiki/whats-next.md +0 -0
  97. {cli_login-0.1.0 → cli_login-0.2.0}/scripts/build_broker.py +0 -0
  98. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/__init___test.py +0 -0
  99. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/__init__.py +0 -0
  100. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/pages.py +0 -0
  101. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/pages_test.py +0 -0
  102. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/transactions.py +0 -0
  103. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/broker/transactions_test.py +0 -0
  104. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/concurrency_test.py +0 -0
  105. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/errors.py +0 -0
  106. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/errors_test.py +0 -0
  107. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/locks.py +0 -0
  108. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/locks_test.py +0 -0
  109. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/oauth.py +0 -0
  110. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/oauth_test.py +0 -0
  111. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/remote.py +0 -0
  112. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/remote_test.py +0 -0
  113. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/__init__.py +0 -0
  114. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/agent.py +0 -0
  115. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/agent_test.py +0 -0
  116. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/kernel_keyring.py +0 -0
  117. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/kernel_keyring_test.py +0 -0
  118. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/macos.py +0 -0
  119. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/macos_test.py +0 -0
  120. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/secret_service.py +0 -0
  121. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/secret_service_test.py +0 -0
  122. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/stores_test.py +0 -0
  123. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/windows.py +0 -0
  124. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/stores/windows_test.py +0 -0
  125. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/target.py +0 -0
  126. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/target_test.py +0 -0
  127. {cli_login-0.1.0 → cli_login-0.2.0}/src/cli_login/testing_processes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-login
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: One CLI login for every Anthus product: PKCE sign-in, remote approval, secure storage and renewal.
5
5
  Author: Anthus AI
6
6
  License: MIT
@@ -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.1.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"
@@ -1,4 +1,4 @@
1
1
  """One CLI login for every Anthus product. See docs/design.md."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
4
4
  PROTOCOL_VERSION = 1
@@ -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
- """Per-caller rate limits for the broker's public routes.
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. A CLI that polls one transaction faster than its
5
- interval is told ``slow_down`` separately, by the poll itself.
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
- return {"content-type": "application/json", "Authorization": self._token}
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