tep 0.11.0

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 (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
data/test/test_auth.rb ADDED
@@ -0,0 +1,223 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Auth + Tep::AuthBearerToken: end-to-end JWT bearer-token
4
+ # auth flow. Boots a tep app that installs the auth filter, mints
5
+ # tokens via Tep::Jwt at request time, and exercises req.identity
6
+ # from handler bodies.
7
+ class TestAuth < TepTest
8
+ app_source <<~RB
9
+ require 'sinatra'
10
+
11
+ SECRET = "test-shared-secret-do-not-use-in-prod"
12
+
13
+ Tep::AuthBearerToken.set_secret(SECRET)
14
+ Tep::Auth.install!
15
+
16
+ # ---- mint endpoints (test harness uses these to get tokens) ----
17
+ # The payload is Tep::Json-friendly flat JSON: sub, exp, caps
18
+ # (comma-separated), and optionally delegate (pipe-encoded).
19
+
20
+ post '/mint_human' do
21
+ res.headers["Content-Type"] = "text/plain"
22
+ sub = Tep::Json.get_str(req.raw_body, "sub")
23
+ caps = Tep::Json.get_str(req.raw_body, "caps")
24
+ exp = Time.now.to_i + 600
25
+ payload = "{" +
26
+ Tep::Json.encode_pair_str("sub", sub) + "," +
27
+ Tep::Json.encode_pair_int("exp", exp) + "," +
28
+ Tep::Json.encode_pair_str("caps", caps) +
29
+ "}"
30
+ Tep::Jwt.encode_hs256(payload, SECRET)
31
+ end
32
+
33
+ post '/mint_agent' do
34
+ res.headers["Content-Type"] = "text/plain"
35
+ sub = Tep::Json.get_str(req.raw_body, "sub")
36
+ caps = Tep::Json.get_str(req.raw_body, "caps")
37
+ delegate = Tep::Json.get_str(req.raw_body, "delegate")
38
+ exp = Time.now.to_i + 600
39
+ payload = "{" +
40
+ Tep::Json.encode_pair_str("sub", sub) + "," +
41
+ Tep::Json.encode_pair_int("exp", exp) + "," +
42
+ Tep::Json.encode_pair_str("caps", caps) + "," +
43
+ Tep::Json.encode_pair_str("delegate", delegate) +
44
+ "}"
45
+ Tep::Jwt.encode_hs256(payload, SECRET)
46
+ end
47
+
48
+ post '/mint_expired' do
49
+ res.headers["Content-Type"] = "text/plain"
50
+ sub = Tep::Json.get_str(req.raw_body, "sub")
51
+ # Issued in the past, expired in the past.
52
+ exp = Time.now.to_i - 60
53
+ payload = "{" +
54
+ Tep::Json.encode_pair_str("sub", sub) + "," +
55
+ Tep::Json.encode_pair_int("exp", exp) + "," +
56
+ Tep::Json.encode_pair_str("caps", "read") +
57
+ "}"
58
+ Tep::Jwt.encode_hs256(payload, SECRET)
59
+ end
60
+
61
+ # ---- identity-introspection endpoints ----
62
+ # Every route below reads req.identity (populated by the
63
+ # auth-filter before this handler runs).
64
+
65
+ before do
66
+ res.headers["Content-Type"] = "text/plain"
67
+ end
68
+
69
+ get '/whoami' do
70
+ req.identity.subject
71
+ end
72
+
73
+ get '/is_human' do
74
+ req.identity.human? ? "yes" : "no"
75
+ end
76
+
77
+ get '/is_agent' do
78
+ req.identity.agent? ? "yes" : "no"
79
+ end
80
+
81
+ get '/may_read' do
82
+ req.identity.may?(:read) ? "yes" : "no"
83
+ end
84
+
85
+ get '/may_write' do
86
+ req.identity.may?(:write) ? "yes" : "no"
87
+ end
88
+
89
+ get '/may_post_summary' do
90
+ req.identity.may?(:post_summary) ? "yes" : "no"
91
+ end
92
+
93
+ get '/agent_id' do
94
+ if req.identity.acting_via == nil
95
+ ""
96
+ else
97
+ req.identity.acting_via.agent_id
98
+ end
99
+ end
100
+ RB
101
+
102
+ # ---- helper: mint a token, then call a route with it as Bearer ----
103
+
104
+ def mint_human(sub, caps)
105
+ body = "{" +
106
+ "\"sub\":\"" + sub + "\"," +
107
+ "\"caps\":\"" + caps + "\"}"
108
+ post("/mint_human", body).body
109
+ end
110
+
111
+ def mint_agent(sub, caps, delegate)
112
+ body = "{" +
113
+ "\"sub\":\"" + sub + "\"," +
114
+ "\"caps\":\"" + caps + "\"," +
115
+ "\"delegate\":\"" + delegate + "\"}"
116
+ post("/mint_agent", body).body
117
+ end
118
+
119
+ def mint_expired(sub)
120
+ body = "{\"sub\":\"" + sub + "\"}"
121
+ post("/mint_expired", body).body
122
+ end
123
+
124
+ def authed(path, token)
125
+ get(path, "Authorization" => "Bearer " + token)
126
+ end
127
+
128
+ # ---- anonymous (no Authorization header) ----
129
+
130
+ def test_anonymous_subject
131
+ assert_equal "user:", get("/whoami").body
132
+ end
133
+
134
+ def test_anonymous_has_no_caps
135
+ assert_equal "no", get("/may_read").body
136
+ end
137
+
138
+ # ---- valid human token ----
139
+
140
+ def test_human_subject_via_bearer
141
+ token = mint_human("user:42", "read,write")
142
+ assert_equal "user:user:42", authed("/whoami", token).body
143
+ end
144
+
145
+ def test_human_marked_human
146
+ token = mint_human("user:42", "read")
147
+ assert_equal "yes", authed("/is_human", token).body
148
+ end
149
+
150
+ def test_human_not_agent
151
+ token = mint_human("user:42", "read")
152
+ assert_equal "no", authed("/is_agent", token).body
153
+ end
154
+
155
+ def test_human_granted_caps
156
+ token = mint_human("user:42", "read,write")
157
+ assert_equal "yes", authed("/may_read", token).body
158
+ assert_equal "yes", authed("/may_write", token).body
159
+ end
160
+
161
+ def test_human_lacks_ungranted_cap
162
+ token = mint_human("user:42", "read")
163
+ assert_equal "no", authed("/may_write", token).body
164
+ end
165
+
166
+ # ---- valid agent token ----
167
+
168
+ def test_agent_subject_format
169
+ token = mint_agent(
170
+ "user:42", "read",
171
+ "summarizer-bot|1000|9999999999|token")
172
+ assert_equal "agent:summarizer-bot/user:42",
173
+ authed("/whoami", token).body
174
+ end
175
+
176
+ def test_agent_marked_agent
177
+ token = mint_agent(
178
+ "user:42", "read",
179
+ "summarizer-bot|1000|9999999999|token")
180
+ assert_equal "yes", authed("/is_agent", token).body
181
+ end
182
+
183
+ def test_agent_id_exposed
184
+ token = mint_agent(
185
+ "user:42", "read",
186
+ "summarizer-bot|1000|9999999999|token")
187
+ assert_equal "summarizer-bot", authed("/agent_id", token).body
188
+ end
189
+
190
+ def test_agent_caps_subset_of_principal
191
+ # Principal would have :read + :write; this token grants :read only.
192
+ # Auth doesn't enforce subset -- issuer does -- but tests that
193
+ # whatever the token carries flows through.
194
+ token = mint_agent(
195
+ "user:42", "read",
196
+ "summarizer-bot|1000|9999999999|token")
197
+ assert_equal "yes", authed("/may_read", token).body
198
+ assert_equal "no", authed("/may_write", token).body
199
+ end
200
+
201
+ # ---- token rejections ----
202
+
203
+ def test_expired_token_falls_back_to_anonymous
204
+ token = mint_expired("user:42")
205
+ assert_equal "user:", authed("/whoami", token).body
206
+ end
207
+
208
+ def test_bad_signature_falls_back_to_anonymous
209
+ token = mint_human("user:42", "read")
210
+ tampered = token + "x"
211
+ assert_equal "user:", authed("/whoami", tampered).body
212
+ end
213
+
214
+ def test_malformed_bearer_header_falls_back_to_anonymous
215
+ # No "Bearer " prefix.
216
+ res = get("/whoami", "Authorization" => "Basic abcdef")
217
+ assert_equal "user:", res.body
218
+ end
219
+
220
+ def test_missing_authorization_falls_back_to_anonymous
221
+ assert_equal "user:", get("/whoami").body
222
+ end
223
+ end
@@ -0,0 +1,208 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::AuthOAuth2: OAuth2-style authorization-code issuance. tep
4
+ # acts as the authorization server -- registers bot clients, issues
5
+ # short-lived codes after consent, exchanges codes for JWTs. The
6
+ # downstream identity surface is the same as direct bearer-token
7
+ # auth: the resulting JWT carries `delegate` and BearerToken parses
8
+ # it into a delegated Tep::Identity.
9
+ class TestAuthOAuth2 < TepTest
10
+ app_source <<~RB
11
+ require 'sinatra'
12
+
13
+ SECRET = "test-oauth2-shared-secret"
14
+ Tep::AuthBearerToken.set_secret(SECRET)
15
+ Tep::Auth.install!
16
+
17
+ # Register one bot client at boot.
18
+ Tep::AuthOAuth2.register_client(
19
+ "summarizer-bot",
20
+ "Summarizer Bot",
21
+ "https://bot.example/oauth/callback",
22
+ [:read, :post_summary])
23
+
24
+ before do
25
+ res.headers["Content-Type"] = "text/plain"
26
+ end
27
+
28
+ # ---- consent endpoint: caller passes the principal + granted
29
+ # caps; app's real implementation would render a consent UI +
30
+ # only reach here on user-approve. The test stub skips the UI.
31
+
32
+ post '/consent' do
33
+ principal_id = Tep::Json.get_str(req.raw_body, "principal_id")
34
+ client_id = Tep::Json.get_str(req.raw_body, "client_id")
35
+ caps_str = Tep::Json.get_str(req.raw_body, "caps")
36
+ Tep::AuthOAuth2.issue_code(principal_id, client_id, caps_str, 0)
37
+ end
38
+
39
+ # ---- token-exchange endpoint: bot redeems code for JWT.
40
+
41
+ post '/token' do
42
+ code = Tep::Json.get_str(req.raw_body, "code")
43
+ client_id = Tep::Json.get_str(req.raw_body, "client_id")
44
+ Tep::AuthOAuth2.exchange_code(code, client_id, 0)
45
+ end
46
+
47
+ # ---- client lookup (sanity check the registry).
48
+
49
+ get '/client/:id' do
50
+ c = Tep::AuthOAuth2.find_client(params[:id])
51
+ if c == nil
52
+ "missing"
53
+ else
54
+ c.name + "|" + c.redirect_uri
55
+ end
56
+ end
57
+
58
+ # ---- identity-introspection endpoints (mirrors test_auth).
59
+
60
+ get '/whoami' do
61
+ req.identity.subject
62
+ end
63
+
64
+ get '/is_agent' do
65
+ req.identity.agent? ? "yes" : "no"
66
+ end
67
+
68
+ get '/agent_id' do
69
+ if req.identity.acting_via == nil
70
+ ""
71
+ else
72
+ req.identity.acting_via.agent_id
73
+ end
74
+ end
75
+
76
+ get '/origin' do
77
+ if req.identity.acting_via == nil
78
+ ""
79
+ else
80
+ req.identity.acting_via.origin.to_s
81
+ end
82
+ end
83
+
84
+ get '/may_read' do
85
+ req.identity.may?(:read) ? "yes" : "no"
86
+ end
87
+
88
+ get '/may_post_summary' do
89
+ req.identity.may?(:post_summary) ? "yes" : "no"
90
+ end
91
+
92
+ get '/may_write' do
93
+ req.identity.may?(:write) ? "yes" : "no"
94
+ end
95
+ RB
96
+
97
+ # ---- helpers ----
98
+
99
+ def consent_body(principal_id, client_id, caps)
100
+ "{" +
101
+ "\"principal_id\":\"" + principal_id + "\"," +
102
+ "\"client_id\":\"" + client_id + "\"," +
103
+ "\"caps\":\"" + caps + "\"}"
104
+ end
105
+
106
+ def token_body(code, client_id)
107
+ "{\"code\":\"" + code + "\",\"client_id\":\"" + client_id + "\"}"
108
+ end
109
+
110
+ def consent(principal_id, client_id, caps)
111
+ post("/consent", consent_body(principal_id, client_id, caps)).body
112
+ end
113
+
114
+ def exchange(code, client_id)
115
+ post("/token", token_body(code, client_id)).body
116
+ end
117
+
118
+ def authed(path, token)
119
+ get(path, "Authorization" => "Bearer " + token)
120
+ end
121
+
122
+ # ---- client registry ----
123
+
124
+ def test_registered_client_lookup
125
+ assert_equal "Summarizer Bot|https://bot.example/oauth/callback",
126
+ get("/client/summarizer-bot").body
127
+ end
128
+
129
+ def test_unregistered_client_lookup
130
+ assert_equal "missing", get("/client/never-registered").body
131
+ end
132
+
133
+ # ---- happy path: issue + exchange ----
134
+
135
+ def test_issue_code_returns_nonempty
136
+ code = consent("user:42", "summarizer-bot", "read")
137
+ refute_equal "", code
138
+ # base64url, 24 random bytes -> ~32 chars
139
+ assert code.length >= 28, "code too short: #{code.inspect}"
140
+ end
141
+
142
+ def test_exchange_code_returns_jwt
143
+ code = consent("user:42", "summarizer-bot", "read,post_summary")
144
+ token = exchange(code, "summarizer-bot")
145
+ refute_equal "", token
146
+ # JWT shape: three dot-separated segments.
147
+ assert_equal 2, token.count("."), "token: #{token.inspect}"
148
+ end
149
+
150
+ def test_exchanged_jwt_authenticates_as_agent
151
+ code = consent("user:42", "summarizer-bot", "read,post_summary")
152
+ token = exchange(code, "summarizer-bot")
153
+ assert_equal "agent:summarizer-bot/user:42",
154
+ authed("/whoami", token).body
155
+ end
156
+
157
+ def test_exchanged_jwt_marked_agent
158
+ code = consent("user:42", "summarizer-bot", "read")
159
+ token = exchange(code, "summarizer-bot")
160
+ assert_equal "yes", authed("/is_agent", token).body
161
+ end
162
+
163
+ def test_exchanged_jwt_carries_agent_id
164
+ code = consent("user:42", "summarizer-bot", "read")
165
+ token = exchange(code, "summarizer-bot")
166
+ assert_equal "summarizer-bot", authed("/agent_id", token).body
167
+ end
168
+
169
+ def test_exchanged_jwt_origin_is_oauth_grant
170
+ code = consent("user:42", "summarizer-bot", "read")
171
+ token = exchange(code, "summarizer-bot")
172
+ assert_equal "oauth_grant", authed("/origin", token).body
173
+ end
174
+
175
+ def test_exchanged_jwt_caps_granted
176
+ code = consent("user:42", "summarizer-bot", "read,post_summary")
177
+ token = exchange(code, "summarizer-bot")
178
+ assert_equal "yes", authed("/may_read", token).body
179
+ assert_equal "yes", authed("/may_post_summary", token).body
180
+ end
181
+
182
+ def test_exchanged_jwt_caps_not_in_grant_are_rejected
183
+ # User granted only :read. The JWT should NOT carry :write.
184
+ code = consent("user:42", "summarizer-bot", "read")
185
+ token = exchange(code, "summarizer-bot")
186
+ assert_equal "no", authed("/may_write", token).body
187
+ end
188
+
189
+ # ---- rejections ----
190
+
191
+ def test_exchange_unknown_code_returns_empty
192
+ assert_equal "", exchange("never-issued-code", "summarizer-bot")
193
+ end
194
+
195
+ def test_exchange_wrong_client_id_returns_empty
196
+ code = consent("user:42", "summarizer-bot", "read")
197
+ # Try to redeem against a different client_id.
198
+ assert_equal "", exchange(code, "different-bot")
199
+ end
200
+
201
+ def test_exchange_is_single_use
202
+ code = consent("user:42", "summarizer-bot", "read")
203
+ # First exchange succeeds.
204
+ refute_equal "", exchange(code, "summarizer-bot")
205
+ # Second exchange of the same code fails.
206
+ assert_equal "", exchange(code, "summarizer-bot")
207
+ end
208
+ end
@@ -0,0 +1,198 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::AuthSessionCookie: signed-session-cookie auth provider.
4
+ # Round-trip an Identity via the tep.session cookie + verify it's
5
+ # read back through req.identity on the next request.
6
+ class TestAuthSessionCookie < TepTest
7
+ app_source <<~RB
8
+ require 'sinatra'
9
+
10
+ Tep.session_secret = "test-session-secret-do-not-use-in-prod"
11
+ Tep::Auth.install!
12
+
13
+ # ---- write paths: the test harness calls these to seed a
14
+ # session cookie. POST body is irrelevant; the route hardcodes
15
+ # the identity it sets so the test can predict the readback.
16
+
17
+ before do
18
+ res.headers["Content-Type"] = "text/plain"
19
+ end
20
+
21
+ post '/login_human' do
22
+ caps = [:read, :write]
23
+ ident = Tep::Identity.new("user:42", nil, caps)
24
+ Tep::AuthSessionCookie.set(req, ident, 0)
25
+ "ok"
26
+ end
27
+
28
+ post '/login_human_with_exp' do
29
+ # Expiry 600s in the future -- valid for the immediate readback.
30
+ caps = [:read]
31
+ ident = Tep::Identity.new("user:42", nil, caps)
32
+ Tep::AuthSessionCookie.set(req, ident, Time.now.to_i + 600)
33
+ "ok"
34
+ end
35
+
36
+ post '/login_human_expired' do
37
+ # Expiry 60s in the PAST -- readback rejects.
38
+ caps = [:read]
39
+ ident = Tep::Identity.new("user:42", nil, caps)
40
+ Tep::AuthSessionCookie.set(req, ident, Time.now.to_i - 60)
41
+ "ok"
42
+ end
43
+
44
+ post '/login_agent' do
45
+ caps = [:read]
46
+ delegation = Tep::AgentDelegation.new(
47
+ "summarizer-bot", 1000, 9999999999, :token)
48
+ ident = Tep::Identity.new("user:42", delegation, caps)
49
+ Tep::AuthSessionCookie.set(req, ident, 0)
50
+ "ok"
51
+ end
52
+
53
+ post '/logout' do
54
+ Tep::AuthSessionCookie.clear(req)
55
+ "ok"
56
+ end
57
+
58
+ # ---- read paths ----
59
+
60
+ get '/whoami' do
61
+ req.identity.subject
62
+ end
63
+
64
+ get '/is_human' do
65
+ req.identity.human? ? "yes" : "no"
66
+ end
67
+
68
+ get '/is_agent' do
69
+ req.identity.agent? ? "yes" : "no"
70
+ end
71
+
72
+ get '/may_read' do
73
+ req.identity.may?(:read) ? "yes" : "no"
74
+ end
75
+
76
+ get '/may_write' do
77
+ req.identity.may?(:write) ? "yes" : "no"
78
+ end
79
+
80
+ get '/agent_id' do
81
+ if req.identity.acting_via == nil
82
+ ""
83
+ else
84
+ req.identity.acting_via.agent_id
85
+ end
86
+ end
87
+ RB
88
+
89
+ # Pull the tep.session cookie out of a Set-Cookie header and
90
+ # return the "tep.session=..." string suitable for a Cookie:
91
+ # request header.
92
+ def session_cookie_from(res)
93
+ raw = res["set-cookie"]
94
+ return nil if raw.nil? || raw.empty?
95
+ pair = raw.split(";").first
96
+ pair.strip
97
+ end
98
+
99
+ # POST to a login route, then GET `path` with the resulting
100
+ # session cookie. Returns the GET response.
101
+ def with_session_from(login_path, path)
102
+ login_res = post(login_path)
103
+ cookie = session_cookie_from(login_res)
104
+ assert cookie, "expected tep.session cookie in #{login_path} response, got: #{login_res['set-cookie'].inspect}"
105
+ get(path, "Cookie" => cookie)
106
+ end
107
+
108
+ # ---- anonymous (no session cookie at all) ----
109
+
110
+ def test_anonymous_when_no_cookie
111
+ assert_equal "user:", get("/whoami").body
112
+ end
113
+
114
+ def test_anonymous_has_no_caps
115
+ assert_equal "no", get("/may_read").body
116
+ end
117
+
118
+ # ---- human identity round-trips through the session ----
119
+
120
+ def test_human_subject_round_trips
121
+ res = with_session_from("/login_human", "/whoami")
122
+ assert_equal "user:user:42", res.body
123
+ end
124
+
125
+ def test_human_marked_human
126
+ res = with_session_from("/login_human", "/is_human")
127
+ assert_equal "yes", res.body
128
+ end
129
+
130
+ def test_human_caps_round_trip
131
+ res = with_session_from("/login_human", "/may_read")
132
+ assert_equal "yes", res.body
133
+ res = with_session_from("/login_human", "/may_write")
134
+ assert_equal "yes", res.body
135
+ end
136
+
137
+ # ---- agent identity round-trips ----
138
+
139
+ def test_agent_subject_round_trips
140
+ res = with_session_from("/login_agent", "/whoami")
141
+ assert_equal "agent:summarizer-bot/user:42", res.body
142
+ end
143
+
144
+ def test_agent_marked_agent
145
+ res = with_session_from("/login_agent", "/is_agent")
146
+ assert_equal "yes", res.body
147
+ end
148
+
149
+ def test_agent_id_round_trips
150
+ res = with_session_from("/login_agent", "/agent_id")
151
+ assert_equal "summarizer-bot", res.body
152
+ end
153
+
154
+ # ---- expiry ----
155
+
156
+ def test_valid_exp_still_works
157
+ res = with_session_from("/login_human_with_exp", "/whoami")
158
+ assert_equal "user:user:42", res.body
159
+ end
160
+
161
+ def test_expired_identity_falls_back_to_anonymous
162
+ res = with_session_from("/login_human_expired", "/whoami")
163
+ assert_equal "user:", res.body
164
+ end
165
+
166
+ # ---- logout ----
167
+
168
+ def test_logout_clears_identity
169
+ # Step 1: log in
170
+ login_res = post("/login_human")
171
+ logged_in_cookie = session_cookie_from(login_res)
172
+
173
+ # Step 2: verify identity is set
174
+ res = get("/whoami", "Cookie" => logged_in_cookie)
175
+ assert_equal "user:user:42", res.body
176
+
177
+ # Step 3: logout (server clears the identity_* keys; the response
178
+ # re-signs the cleared cookie and returns it).
179
+ logout_res = post("/logout", "", "Cookie" => logged_in_cookie)
180
+ logged_out_cookie = session_cookie_from(logout_res)
181
+
182
+ # Step 4: subsequent request with the post-logout cookie sees
183
+ # anonymous.
184
+ res = get("/whoami", "Cookie" => logged_out_cookie)
185
+ assert_equal "user:", res.body
186
+ end
187
+
188
+ # ---- tampering ----
189
+
190
+ def test_tampered_cookie_falls_back_to_anonymous
191
+ login_res = post("/login_human")
192
+ cookie = session_cookie_from(login_res)
193
+ # Mangle the signature half (everything after the last dot).
194
+ tampered = cookie.sub(/\.[^.]+\z/, ".aaaaaaaa")
195
+ res = get("/whoami", "Cookie" => tampered)
196
+ assert_equal "user:", res.body
197
+ end
198
+ end