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.
Files changed (129) hide show
  1. {cli_login-0.1.0 → cli_login-0.3.0}/PKG-INFO +1 -1
  2. {cli_login-0.1.0 → cli_login-0.3.0}/docs/protocol.md +7 -2
  3. {cli_login-0.1.0 → cli_login-0.3.0}/infra/broker.yaml +25 -0
  4. {cli_login-0.1.0 → cli_login-0.3.0}/infra/broker_template_test.py +7 -0
  5. cli_login-0.3.0/project/events/2026-10-30T14-01-17.888Z__bf995622-7f63-4936-9891-4cebf7208cf5.json +12 -0
  6. cli_login-0.3.0/project/events/2026-10-30T14-01-17.900Z__e9675700-922f-449e-8b9f-9779917ce373.json +12 -0
  7. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-96ed2301-f038-4d76-84bb-b670184ab538.json +10 -3
  8. {cli_login-0.1.0 → cli_login-0.3.0}/pyproject.toml +1 -1
  9. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/__init__.py +1 -1
  10. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/handler.py +21 -2
  11. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/handler_test.py +50 -0
  12. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/pages.py +16 -2
  13. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/pages_test.py +39 -0
  14. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/throttle.py +6 -3
  15. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/remote.py +12 -1
  16. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/remote_test.py +43 -3
  17. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/session.py +24 -1
  18. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/session_test.py +60 -0
  19. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/testing.py +10 -0
  20. {cli_login-0.1.0 → cli_login-0.3.0}/.claude/settings.json +0 -0
  21. {cli_login-0.1.0 → cli_login-0.3.0}/.cursorignore +0 -0
  22. {cli_login-0.1.0 → cli_login-0.3.0}/.github/workflows/release.yml +0 -0
  23. {cli_login-0.1.0 → cli_login-0.3.0}/.github/workflows/tests.yml +0 -0
  24. {cli_login-0.1.0 → cli_login-0.3.0}/.gitignore +0 -0
  25. {cli_login-0.1.0 → cli_login-0.3.0}/.kanbus.yml +0 -0
  26. {cli_login-0.1.0 → cli_login-0.3.0}/.vscode/settings.json +0 -0
  27. {cli_login-0.1.0 → cli_login-0.3.0}/AGENTS.md +0 -0
  28. {cli_login-0.1.0 → cli_login-0.3.0}/CONTRIBUTING_AGENT.md +0 -0
  29. {cli_login-0.1.0 → cli_login-0.3.0}/CONTRIBUTING_AGENT.template.md +0 -0
  30. {cli_login-0.1.0 → cli_login-0.3.0}/LICENSE +0 -0
  31. {cli_login-0.1.0 → cli_login-0.3.0}/README.md +0 -0
  32. {cli_login-0.1.0 → cli_login-0.3.0}/docs/design.md +0 -0
  33. {cli_login-0.1.0 → cli_login-0.3.0}/features/environment.py +0 -0
  34. {cli_login-0.1.0 → cli_login-0.3.0}/features/locks.feature +0 -0
  35. {cli_login-0.1.0 → cli_login-0.3.0}/features/login_session.feature +0 -0
  36. {cli_login-0.1.0 → cli_login-0.3.0}/features/oauth_provider.feature +0 -0
  37. {cli_login-0.1.0 → cli_login-0.3.0}/features/remote_sign_in.feature +0 -0
  38. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/locks_steps.py +0 -0
  39. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/login_session_steps.py +0 -0
  40. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/oauth_provider_steps.py +0 -0
  41. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/remote_sign_in_steps.py +0 -0
  42. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/store_common_steps.py +0 -0
  43. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_agent_steps.py +0 -0
  44. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_kernel_keyring_steps.py +0 -0
  45. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_macos_steps.py +0 -0
  46. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_secret_service_steps.py +0 -0
  47. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_selection_steps.py +0 -0
  48. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/stores_windows_steps.py +0 -0
  49. {cli_login-0.1.0 → cli_login-0.3.0}/features/steps/target_steps.py +0 -0
  50. {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_agent.feature +0 -0
  51. {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_agent_windows.feature +0 -0
  52. {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_kernel_keyring.feature +0 -0
  53. {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_macos.feature +0 -0
  54. {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_secret_service.feature +0 -0
  55. {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_selection.feature +0 -0
  56. {cli_login-0.1.0 → cli_login-0.3.0}/features/stores_windows.feature +0 -0
  57. {cli_login-0.1.0 → cli_login-0.3.0}/features/target.feature +0 -0
  58. {cli_login-0.1.0 → cli_login-0.3.0}/project/AGENTS.md +0 -0
  59. {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
  60. {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
  61. {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
  62. {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
  63. {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
  64. {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
  65. {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
  66. {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
  67. {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
  68. {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
  69. {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
  70. {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
  71. {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
  72. {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
  73. {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
  74. {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
  75. {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
  76. {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
  77. {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
  78. {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
  79. {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
  80. {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
  81. {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
  82. {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
  83. {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
  84. {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
  85. {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
  86. {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
  87. {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
  88. {cli_login-0.1.0 → cli_login-0.3.0}/project/events/AGENTS.md +0 -0
  89. {cli_login-0.1.0 → cli_login-0.3.0}/project/events/DO_NOT_EDIT +0 -0
  90. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/AGENTS.md +0 -0
  91. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/DO_NOT_EDIT +0 -0
  92. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-04de9a1b-0c3e-4589-b9b5-ae30cd3cdd18.json +0 -0
  93. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-1b4a091f-2bfc-405c-be6e-b787b4bb4696.json +0 -0
  94. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-31750ff1-8d69-4b03-b953-82da220c1a9f.json +0 -0
  95. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4.json +0 -0
  96. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-361d5c51-7951-4e46-9c57-a64cb6f8058f.json +0 -0
  97. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-85e67311-6ca7-4c57-9e44-4d414ebc618f.json +0 -0
  98. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-9a02d68d-d3ff-4dfd-a319-2c411f05fddd.json +0 -0
  99. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-cccc972a-7df2-4795-9f9b-d8805dfc142e.json +0 -0
  100. {cli_login-0.1.0 → cli_login-0.3.0}/project/issues/cli-login-ff76cbbc-d47b-49d5-91e1-4aa2ae15e99f.json +0 -0
  101. {cli_login-0.1.0 → cli_login-0.3.0}/project/wiki/index.md +0 -0
  102. {cli_login-0.1.0 → cli_login-0.3.0}/project/wiki/whats-next.md +0 -0
  103. {cli_login-0.1.0 → cli_login-0.3.0}/scripts/build_broker.py +0 -0
  104. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/__init___test.py +0 -0
  105. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/__init__.py +0 -0
  106. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/transactions.py +0 -0
  107. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/broker/transactions_test.py +0 -0
  108. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/concurrency_test.py +0 -0
  109. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/errors.py +0 -0
  110. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/errors_test.py +0 -0
  111. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/locks.py +0 -0
  112. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/locks_test.py +0 -0
  113. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/oauth.py +0 -0
  114. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/oauth_test.py +0 -0
  115. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/__init__.py +0 -0
  116. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/agent.py +0 -0
  117. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/agent_test.py +0 -0
  118. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/kernel_keyring.py +0 -0
  119. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/kernel_keyring_test.py +0 -0
  120. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/macos.py +0 -0
  121. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/macos_test.py +0 -0
  122. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/secret_service.py +0 -0
  123. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/secret_service_test.py +0 -0
  124. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/stores_test.py +0 -0
  125. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/windows.py +0 -0
  126. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/stores/windows_test.py +0 -0
  127. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/target.py +0 -0
  128. {cli_login-0.1.0 → cli_login-0.3.0}/src/cli_login/target_test.py +0 -0
  129. {cli_login-0.1.0 → cli_login-0.3.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.3.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
@@ -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
@@ -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
+ }
@@ -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": "in_progress",
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-30T11:58:11.250332Z",
36
- "closed_at": null,
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.1.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"
@@ -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.3.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}
@@ -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
- return 200, {"protocol": PROTOCOL_VERSION, "code": taken["code"], "redirect_uri": self.config.callback_uri}
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
- bound = self.store.transition(event["transaction"], [PENDING], {"state": BOUND, "nonce_hash": nonce_hash})
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
- if not self.invoke({"operation": "bind", "transaction": transaction, "nonce_hash": hashlib.sha256(nonce.encode()).hexdigest()}).get("ok"):
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": described["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
- """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:
@@ -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 claims.get("nonce") != nonce:
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
- self.broker.bind({"transaction": self.transaction, "nonce_hash": sha256_hex("browser")})
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
- 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