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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- 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
|