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/lib/tep/assets.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Tep::Assets -- in-binary static asset store.
|
|
2
|
+
#
|
|
3
|
+
# Spinel produces a single static binary, so the natural way to
|
|
4
|
+
# ship CSS / images / JS that an app needs is to bake them INTO
|
|
5
|
+
# that binary rather than rely on a sibling `public/` directory.
|
|
6
|
+
# The build-time translator (bin/tep) auto-discovers everything
|
|
7
|
+
# under `<app_dir>/assets/` and emits `_add` calls that register
|
|
8
|
+
# each file's bytes + content-type before any handler runs.
|
|
9
|
+
#
|
|
10
|
+
# The actual storage lives on the Tep::App singleton (`APP`):
|
|
11
|
+
# two str_hashes keyed by path, one for body bytes and one for
|
|
12
|
+
# mime. Routing via the app instance keeps spinel's class-var /
|
|
13
|
+
# constant inference simple -- both are well-tracked instance
|
|
14
|
+
# variables on a class with an explicit initialiser.
|
|
15
|
+
#
|
|
16
|
+
# Conventions
|
|
17
|
+
# -----------
|
|
18
|
+
# * The asset is served at `/<relative path>` from the project's
|
|
19
|
+
# `assets/` dir. So `assets/logo.svg` -> `GET /logo.svg`.
|
|
20
|
+
# * MIME type inferred from extension at build time.
|
|
21
|
+
# * Binary assets pass through as Ruby string literals; spinel
|
|
22
|
+
# carries the bytes through to the C compile as const char *.
|
|
23
|
+
# NUL bytes truncate (spinel's :str doesn't track length), so
|
|
24
|
+
# binary assets containing 0x00 should be served via
|
|
25
|
+
# `Tep.public_dir` instead.
|
|
26
|
+
module Tep
|
|
27
|
+
class Assets
|
|
28
|
+
def self._add(path, body, mime)
|
|
29
|
+
Tep::APP.add_asset(path, body, mime)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.has?(path)
|
|
33
|
+
Tep::APP.asset_bodies.has_key?(path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Serve `path` if it's known. Sets Content-Type / body and
|
|
37
|
+
# returns true; returns false if the path isn't bundled.
|
|
38
|
+
def self.serve(path, res)
|
|
39
|
+
if !Tep::APP.asset_bodies.has_key?(path)
|
|
40
|
+
return false
|
|
41
|
+
end
|
|
42
|
+
res.headers["Content-Type"] = Tep::APP.asset_mimes[path]
|
|
43
|
+
res.headers["Cache-Control"] = "public, max-age=3600"
|
|
44
|
+
# Content-hash ETag (#152): lets the browser revalidate with
|
|
45
|
+
# If-None-Match and get a 304 (handled by the server's
|
|
46
|
+
# Tep::Cache short-circuit) instead of re-downloading the body.
|
|
47
|
+
res.etag(Tep::APP.asset_etags[path])
|
|
48
|
+
res.set_body_if_empty(Tep::APP.asset_bodies[path])
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/tep/auth.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Tep::Auth -- the entry point for the Auth battery.
|
|
2
|
+
#
|
|
3
|
+
# Sets `req.identity` (a Tep::Identity) on every request, populated
|
|
4
|
+
# by walking a fixed provider chain. Three providers ship:
|
|
5
|
+
# Tep::AuthBearerToken (JWT HS256), Tep::AuthSessionCookie
|
|
6
|
+
# (signed cookie), Tep::AuthOAuth2 (delegated-grant exchange).
|
|
7
|
+
# Each one extends the chain by editing Tep::Auth.identify
|
|
8
|
+
# (rather than via a runtime registry, because spinel's
|
|
9
|
+
# PtrArray<Base> dispatch can't carry cls_id across heterogeneous
|
|
10
|
+
# Provider subclasses -- see memory [[spinel_widening_dispatch]]).
|
|
11
|
+
# Once spinel resolves the cls_id story the design doc's
|
|
12
|
+
# Tep::Auth.providers.add(...) API will land; until then the
|
|
13
|
+
# fixed-chain shape stays.
|
|
14
|
+
#
|
|
15
|
+
# Install pattern:
|
|
16
|
+
#
|
|
17
|
+
# require 'sinatra'
|
|
18
|
+
# Tep::AuthBearerToken.set_secret(ENV["JWT_SECRET"])
|
|
19
|
+
# Tep::Auth.install!
|
|
20
|
+
#
|
|
21
|
+
# # In handlers, req.identity is always populated -- either with
|
|
22
|
+
# # the bearer's identity or with Tep::Identity.anonymous.
|
|
23
|
+
# get '/me' do
|
|
24
|
+
# req.identity.subject
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# The auth filter is a SEPARATE slot from the user-installed
|
|
28
|
+
# before-filter (see Tep::App#auth_filter). Both run, in order:
|
|
29
|
+
# auth-filter first (populates req.identity), then user
|
|
30
|
+
# before-filter (sees a fully-populated identity). This avoids the
|
|
31
|
+
# "one filter slot" composition tax tep otherwise imposes.
|
|
32
|
+
module Tep
|
|
33
|
+
module Auth
|
|
34
|
+
CORE_CAPABILITIES = [:read, :write, :authn, :authz]
|
|
35
|
+
|
|
36
|
+
# Walk the provider chain. First provider that returns a non-nil
|
|
37
|
+
# Identity wins. Returns nil if no provider matched -- caller is
|
|
38
|
+
# responsible for substituting Tep::Identity.anonymous.
|
|
39
|
+
#
|
|
40
|
+
# Order: BearerToken first (an explicit Authorization header is
|
|
41
|
+
# a stronger signal of caller intent than a passively-replayed
|
|
42
|
+
# cookie), then SessionCookie. Apps that want cookie-wins-bearer
|
|
43
|
+
# semantics can post-process req.identity in a before-filter.
|
|
44
|
+
def self.identify(req)
|
|
45
|
+
ident = Tep::AuthBearerToken.try(req)
|
|
46
|
+
if ident != nil
|
|
47
|
+
return ident
|
|
48
|
+
end
|
|
49
|
+
ident = Tep::AuthSessionCookie.try(req)
|
|
50
|
+
if ident != nil
|
|
51
|
+
return ident
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Replaces the app's auth-filter slot with the real
|
|
57
|
+
# populate-req.identity filter. Idempotent.
|
|
58
|
+
def self.install!
|
|
59
|
+
Tep::APP.set_auth_filter(Tep::AuthFilter.new)
|
|
60
|
+
0
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The before-filter that runs the provider chain and writes the
|
|
65
|
+
# result to req.identity. Lives at top level (not Tep::Auth::Filter)
|
|
66
|
+
# to keep dispatch simple under spinel.
|
|
67
|
+
class AuthFilter < Tep::Filter
|
|
68
|
+
def before(req, res)
|
|
69
|
+
ident = Tep::Auth.identify(req)
|
|
70
|
+
if ident == nil
|
|
71
|
+
req.identity = Tep::Identity.anonymous
|
|
72
|
+
else
|
|
73
|
+
req.identity = ident
|
|
74
|
+
end
|
|
75
|
+
0
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Tep::AuthBearerToken -- JWT-HS256 bearer-token provider for the
|
|
2
|
+
# Auth battery. Sniffs `Authorization: Bearer <token>`, verifies
|
|
3
|
+
# the signature with the app's configured secret, decodes the
|
|
4
|
+
# flat-JSON payload, and builds a Tep::Identity (with optional
|
|
5
|
+
# Tep::AgentDelegation when the token represents an agent).
|
|
6
|
+
#
|
|
7
|
+
# Configuration:
|
|
8
|
+
#
|
|
9
|
+
# Tep::AuthBearerToken.set_secret(ENV["JWT_SECRET"])
|
|
10
|
+
#
|
|
11
|
+
# Token payload schema (flat JSON, single level -- matches
|
|
12
|
+
# Tep::Json's flat-object extraction surface):
|
|
13
|
+
#
|
|
14
|
+
# {
|
|
15
|
+
# "sub": "user:42", # principal_id (required)
|
|
16
|
+
# "exp": 1716396000, # unix epoch seconds
|
|
17
|
+
# "caps": "read,write,post_summary", # comma-separated symbols
|
|
18
|
+
# "delegate": "summarizer-bot|1716392400|1716396000|token"
|
|
19
|
+
# # optional; presence flips
|
|
20
|
+
# # the identity to an agent.
|
|
21
|
+
# # Format:
|
|
22
|
+
# # agent_id|issued_at|expires_at|origin
|
|
23
|
+
# }
|
|
24
|
+
#
|
|
25
|
+
# Why flat (not nested `acting_via: { ... }`): Tep::Json today
|
|
26
|
+
# extracts flat keys only. A nested-object getter is a separate
|
|
27
|
+
# tiny battery; for v1 of Auth the flat pipe-encoded delegate
|
|
28
|
+
# string is the smallest thing that ships and round-trips
|
|
29
|
+
# cleanly. The Identity / AgentDelegation Ruby surface stays
|
|
30
|
+
# nested -- the encoding is only on the wire.
|
|
31
|
+
#
|
|
32
|
+
# Why a flat top-level class name (not Tep::Auth::BearerToken):
|
|
33
|
+
# two-level namespacing on classes carries spinel cls_id risk
|
|
34
|
+
# (see memory note [[spinel_widening_dispatch]]). The Tep::Auth
|
|
35
|
+
# module owns the conceptual grouping; the class itself lives at
|
|
36
|
+
# Tep:: level so dispatch is shallow.
|
|
37
|
+
module Tep
|
|
38
|
+
class AuthBearerToken
|
|
39
|
+
# Set the shared HMAC secret. Apps call once at boot.
|
|
40
|
+
def self.set_secret(s)
|
|
41
|
+
Tep::APP.set_auth_bearer_secret(s)
|
|
42
|
+
0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Attempt to identify the request. Returns a Tep::Identity on
|
|
46
|
+
# successful verification, nil if no Bearer header / bad
|
|
47
|
+
# signature / expired / malformed payload.
|
|
48
|
+
def self.try(req)
|
|
49
|
+
header = req.req_headers["authorization"]
|
|
50
|
+
if header.length < 8 || header[0, 7] != "Bearer "
|
|
51
|
+
return nil
|
|
52
|
+
end
|
|
53
|
+
token = header[7, header.length - 7]
|
|
54
|
+
|
|
55
|
+
secret = Tep::APP.auth_bearer_secret
|
|
56
|
+
if secret.length == 0
|
|
57
|
+
return nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
payload = Tep::Jwt.verify_and_decode(token, secret)
|
|
61
|
+
if payload.length == 0
|
|
62
|
+
return nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check expiry first -- a token whose exp passed gets rejected
|
|
66
|
+
# even if the signature still verifies. exp is unix epoch sec.
|
|
67
|
+
exp = Tep::Json.get_int(payload, "exp")
|
|
68
|
+
if exp > 0 && Time.now.to_i >= exp
|
|
69
|
+
return nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sub = Tep::Json.get_str(payload, "sub")
|
|
73
|
+
if sub.length == 0
|
|
74
|
+
return nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
caps_str = Tep::Json.get_str(payload, "caps")
|
|
78
|
+
caps = Tep::AuthBearerToken.parse_caps(caps_str)
|
|
79
|
+
|
|
80
|
+
delegate_str = Tep::Json.get_str(payload, "delegate")
|
|
81
|
+
delegation = Tep::AuthBearerToken.parse_delegate(delegate_str)
|
|
82
|
+
|
|
83
|
+
Tep::Identity.new(sub, delegation, caps)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# "read,write,post_summary" -> [:read, :write, :post_summary]
|
|
87
|
+
def self.parse_caps(s)
|
|
88
|
+
caps = [:_seed]
|
|
89
|
+
caps.delete_at(0)
|
|
90
|
+
if s.length == 0
|
|
91
|
+
return caps
|
|
92
|
+
end
|
|
93
|
+
s.split(",").each do |name|
|
|
94
|
+
if name.length > 0
|
|
95
|
+
caps.push(name.to_sym)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
caps
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# "agent_id|issued_at|expires_at|origin" -> AgentDelegation, or
|
|
102
|
+
# nil for empty / malformed. The four-segment pipe encoding
|
|
103
|
+
# avoids the nested-JSON limitation; pipes don't appear in
|
|
104
|
+
# agent ids (we constrain the issuance side).
|
|
105
|
+
#
|
|
106
|
+
# `.to_s` on parts[0] is a no-op type-witness for spinel:
|
|
107
|
+
# without it the inference for the first AgentDelegation arg
|
|
108
|
+
# widens to mrb_int in some larger-codebase compile paths (no
|
|
109
|
+
# other call site constrains agent_id to a String), and the
|
|
110
|
+
# generated C compares pointer-to-int.
|
|
111
|
+
def self.parse_delegate(s)
|
|
112
|
+
if s.length == 0
|
|
113
|
+
return nil
|
|
114
|
+
end
|
|
115
|
+
parts = s.split("|")
|
|
116
|
+
if parts.length < 4
|
|
117
|
+
return nil
|
|
118
|
+
end
|
|
119
|
+
agent_id = parts[0].to_s
|
|
120
|
+
issued_at = parts[1].to_i
|
|
121
|
+
expires_at = parts[2].to_i
|
|
122
|
+
origin = parts[3].to_sym
|
|
123
|
+
Tep::AgentDelegation.new(agent_id, issued_at, expires_at, origin)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# Tep::AuthOAuth2 -- the OAuth2-style authorization-code issuance
|
|
2
|
+
# surface. tep here is the AUTHORIZATION SERVER (not the OAuth
|
|
3
|
+
# client) -- the entity that issues delegated-access tokens to
|
|
4
|
+
# bots / agents / automation clients on behalf of human users.
|
|
5
|
+
#
|
|
6
|
+
# Flow (apps wire their own /oauth/authorize + /oauth/token routes
|
|
7
|
+
# on top of these primitives):
|
|
8
|
+
#
|
|
9
|
+
# 1. Bot redirects the user to /oauth/authorize?client_id=summarizer-bot
|
|
10
|
+
# &redirect_uri=...&caps=read,post_summary.
|
|
11
|
+
# 2. App's /authorize route looks up the client, checks the
|
|
12
|
+
# caps subset against allowed_caps, renders a consent
|
|
13
|
+
# screen ("summarizer-bot wants to act on your behalf...").
|
|
14
|
+
# 3. User clicks "Allow". App calls:
|
|
15
|
+
# Tep::AuthOAuth2.issue_code(req.identity.principal_id,
|
|
16
|
+
# client_id, caps_str, 600)
|
|
17
|
+
# and redirects to the bot's redirect_uri with ?code=<code>.
|
|
18
|
+
# 4. Bot exchanges the code at /oauth/token:
|
|
19
|
+
# Tep::AuthOAuth2.exchange_code(code, client_id)
|
|
20
|
+
# which returns a JWT whose `delegate` field is populated
|
|
21
|
+
# (acting_via on the resulting Tep::Identity).
|
|
22
|
+
# 5. Bot uses the JWT as a Bearer token. Tep::AuthBearerToken
|
|
23
|
+
# parses it; req.identity is a delegated agent identity.
|
|
24
|
+
#
|
|
25
|
+
# The "agentic" framing: this is fundamentally OAuth2 with the
|
|
26
|
+
# semantic shift that the granted token represents an agent
|
|
27
|
+
# delegated by the user, not an "app" the user wants to share
|
|
28
|
+
# data with. The consent UI's wording (rendered by the app, not
|
|
29
|
+
# by tep) should make that clear to the user.
|
|
30
|
+
#
|
|
31
|
+
# Token issuance reuses Tep::Jwt + Tep::AuthBearerToken's wire
|
|
32
|
+
# format -- no new token schema. The downstream Identity surface
|
|
33
|
+
# is the same: `req.identity.agent?` is true, `acting_via.agent_id`
|
|
34
|
+
# is the client_id, `acting_via.origin` is :oauth_grant.
|
|
35
|
+
#
|
|
36
|
+
# Storage is per-process (Tep::APP attrs). High-fanout setups
|
|
37
|
+
# wanting cross-worker code redemption need a PG-backed extension;
|
|
38
|
+
# noted but not in scope for v1.
|
|
39
|
+
module Tep
|
|
40
|
+
module AuthOAuth2
|
|
41
|
+
# Default code TTL (seconds). Apps that need shorter / longer
|
|
42
|
+
# pass an explicit ttl_seconds to issue_code.
|
|
43
|
+
DEFAULT_CODE_TTL = 600
|
|
44
|
+
|
|
45
|
+
# Default token TTL (seconds). The JWT exp claim is set to
|
|
46
|
+
# `now + this`. Apps that need a different window pass an
|
|
47
|
+
# explicit token_ttl_seconds to exchange_code.
|
|
48
|
+
DEFAULT_TOKEN_TTL = 3600
|
|
49
|
+
|
|
50
|
+
# Register a client (bot / agent / automation peer) with the
|
|
51
|
+
# authorization server. Subsequent issue_code and exchange_code
|
|
52
|
+
# calls reference it by client_id. Re-registering an existing
|
|
53
|
+
# client_id replaces the prior entry.
|
|
54
|
+
def self.register_client(client_id, name, redirect_uri, allowed_caps)
|
|
55
|
+
Tep::AuthOAuth2.unregister_client(client_id)
|
|
56
|
+
client = Tep::AuthOAuth2Client.new(
|
|
57
|
+
client_id, name, redirect_uri, allowed_caps)
|
|
58
|
+
Tep::APP.auth_oauth2_clients.push(client)
|
|
59
|
+
0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.unregister_client(client_id)
|
|
63
|
+
clients = Tep::APP.auth_oauth2_clients
|
|
64
|
+
i = 0
|
|
65
|
+
while i < clients.length
|
|
66
|
+
if clients[i].client_id == client_id
|
|
67
|
+
clients.delete_at(i)
|
|
68
|
+
return 0
|
|
69
|
+
end
|
|
70
|
+
i += 1
|
|
71
|
+
end
|
|
72
|
+
0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.find_client(client_id)
|
|
76
|
+
clients = Tep::APP.auth_oauth2_clients
|
|
77
|
+
i = 0
|
|
78
|
+
while i < clients.length
|
|
79
|
+
if clients[i].client_id == client_id
|
|
80
|
+
return clients[i]
|
|
81
|
+
end
|
|
82
|
+
i += 1
|
|
83
|
+
end
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Mint a one-time code tied to (principal, client, granted_caps).
|
|
88
|
+
# Caller (the app's /authorize handler) is responsible for
|
|
89
|
+
# validating that granted_caps is a subset of the client's
|
|
90
|
+
# allowed_caps before calling -- the issuance surface itself
|
|
91
|
+
# trusts the caller.
|
|
92
|
+
#
|
|
93
|
+
# `caps_str` is comma-separated (matches Tep::AuthBearerToken's
|
|
94
|
+
# wire format). `ttl_seconds` is the lifetime; pass 0 for
|
|
95
|
+
# DEFAULT_CODE_TTL.
|
|
96
|
+
#
|
|
97
|
+
# Returns the opaque code string (base64url, ~32 chars).
|
|
98
|
+
def self.issue_code(principal_id, client_id, caps_str, ttl_seconds)
|
|
99
|
+
Tep::AuthOAuth2.sweep_expired_codes
|
|
100
|
+
ttl = ttl_seconds
|
|
101
|
+
if ttl <= 0
|
|
102
|
+
ttl = DEFAULT_CODE_TTL
|
|
103
|
+
end
|
|
104
|
+
code = Crypto.sp_crypto_random_b64url(24)
|
|
105
|
+
expires_at = Time.now.to_i + ttl
|
|
106
|
+
rec = Tep::AuthOAuth2Code.new(
|
|
107
|
+
code, principal_id, client_id, caps_str, expires_at)
|
|
108
|
+
Tep::APP.auth_oauth2_codes.push(rec)
|
|
109
|
+
code
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Redeem a code for a JWT. The code MUST have been issued for
|
|
113
|
+
# this exact client_id (no cross-client redemption). Returns
|
|
114
|
+
# the JWT string on success, "" on failure (unknown code,
|
|
115
|
+
# client_id mismatch, expired, already-redeemed).
|
|
116
|
+
#
|
|
117
|
+
# The JWT is single-use against the registry: a successful
|
|
118
|
+
# exchange_code removes the code from the registry.
|
|
119
|
+
#
|
|
120
|
+
# `token_ttl_seconds` is the JWT's exp lifetime; pass 0 for
|
|
121
|
+
# DEFAULT_TOKEN_TTL.
|
|
122
|
+
def self.exchange_code(code, client_id, token_ttl_seconds)
|
|
123
|
+
Tep::AuthOAuth2.sweep_expired_codes
|
|
124
|
+
codes = Tep::APP.auth_oauth2_codes
|
|
125
|
+
idx = -1
|
|
126
|
+
i = 0
|
|
127
|
+
while i < codes.length
|
|
128
|
+
if codes[i].code == code && codes[i].client_id == client_id
|
|
129
|
+
idx = i
|
|
130
|
+
i = codes.length
|
|
131
|
+
else
|
|
132
|
+
i += 1
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
if idx < 0
|
|
136
|
+
return ""
|
|
137
|
+
end
|
|
138
|
+
rec = codes[idx]
|
|
139
|
+
codes.delete_at(idx)
|
|
140
|
+
if rec.expired?(Time.now.to_i)
|
|
141
|
+
return ""
|
|
142
|
+
end
|
|
143
|
+
Tep::AuthOAuth2.mint_jwt(rec, token_ttl_seconds)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Build the JWT payload and sign it. Uses Tep::Jwt with the
|
|
147
|
+
# same shared secret as Tep::AuthBearerToken, so apps don't
|
|
148
|
+
# need to manage a second secret -- one HS256 secret signs all
|
|
149
|
+
# tokens regardless of issuance path.
|
|
150
|
+
def self.mint_jwt(rec, token_ttl_seconds)
|
|
151
|
+
secret = Tep::APP.auth_bearer_secret
|
|
152
|
+
if secret.length == 0
|
|
153
|
+
return ""
|
|
154
|
+
end
|
|
155
|
+
ttl = token_ttl_seconds
|
|
156
|
+
if ttl <= 0
|
|
157
|
+
ttl = DEFAULT_TOKEN_TTL
|
|
158
|
+
end
|
|
159
|
+
now_ts = Time.now.to_i
|
|
160
|
+
exp_ts = now_ts + ttl
|
|
161
|
+
delegate_str = rec.client_id + "|" + now_ts.to_s + "|" +
|
|
162
|
+
exp_ts.to_s + "|oauth_grant"
|
|
163
|
+
payload = "{" +
|
|
164
|
+
Tep::Json.encode_pair_str("sub", rec.principal_id) + "," +
|
|
165
|
+
Tep::Json.encode_pair_int("exp", exp_ts) + "," +
|
|
166
|
+
Tep::Json.encode_pair_str("caps", rec.caps_str) + "," +
|
|
167
|
+
Tep::Json.encode_pair_str("delegate", delegate_str) +
|
|
168
|
+
"}"
|
|
169
|
+
Tep::Jwt.encode_hs256(payload, secret)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Walk the code registry, drop entries whose expires_at has
|
|
173
|
+
# passed. Called on every issue / exchange so the registry
|
|
174
|
+
# doesn't grow unboundedly even without explicit pruning.
|
|
175
|
+
# Back-to-front so delete_at indices stay valid mid-loop.
|
|
176
|
+
def self.sweep_expired_codes
|
|
177
|
+
codes = Tep::APP.auth_oauth2_codes
|
|
178
|
+
now_ts = Time.now.to_i
|
|
179
|
+
i = codes.length - 1
|
|
180
|
+
while i >= 0
|
|
181
|
+
if codes[i].expired?(now_ts)
|
|
182
|
+
codes.delete_at(i)
|
|
183
|
+
end
|
|
184
|
+
i -= 1
|
|
185
|
+
end
|
|
186
|
+
0
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Tep::AuthOAuth2Client -- one entry in the OAuth2 client registry.
|
|
2
|
+
# Represents a "bot" / "agent" / "automation client" that can be
|
|
3
|
+
# delegated permissions to act on behalf of a human principal via
|
|
4
|
+
# the OAuth2-style authorization-code flow.
|
|
5
|
+
#
|
|
6
|
+
# Created by Tep::AuthOAuth2.register_client. Apps don't typically
|
|
7
|
+
# instantiate this directly -- the registry takes
|
|
8
|
+
# (client_id, name, redirect_uri, allowed_caps) and stores the
|
|
9
|
+
# resulting Client.
|
|
10
|
+
#
|
|
11
|
+
# `allowed_caps` is the MAXIMUM set of capabilities this client
|
|
12
|
+
# can ever be granted. At consent time the human grants a subset
|
|
13
|
+
# (or all) of these to the specific code being issued. The granted
|
|
14
|
+
# set on the eventual JWT is always a subset of allowed_caps.
|
|
15
|
+
module Tep
|
|
16
|
+
class AuthOAuth2Client
|
|
17
|
+
attr_reader :client_id # String, opaque (e.g. "summarizer-bot")
|
|
18
|
+
attr_reader :name # Human-readable display name for consent UI
|
|
19
|
+
attr_reader :redirect_uri # Where to redirect with ?code=... after consent
|
|
20
|
+
attr_reader :allowed_caps # Array of symbols (ceiling on granted caps)
|
|
21
|
+
|
|
22
|
+
def initialize(client_id, name, redirect_uri, allowed_caps)
|
|
23
|
+
@client_id = client_id
|
|
24
|
+
@name = name
|
|
25
|
+
@redirect_uri = redirect_uri
|
|
26
|
+
@allowed_caps = allowed_caps
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Tep::AuthOAuth2Code -- one entry in the short-lived
|
|
2
|
+
# authorization-code registry. Created by
|
|
3
|
+
# Tep::AuthOAuth2.issue_code at the moment a human consents to a
|
|
4
|
+
# specific (client, caps) grant; consumed by
|
|
5
|
+
# Tep::AuthOAuth2.exchange_code when the client redeems the code
|
|
6
|
+
# for a JWT.
|
|
7
|
+
#
|
|
8
|
+
# Codes are single-use and short-lived (typically 5-10 minutes).
|
|
9
|
+
# The registry sweeps expired entries on every lookup so
|
|
10
|
+
# memory doesn't accumulate even without explicit pruning.
|
|
11
|
+
#
|
|
12
|
+
# Storage scope is per-process: the registry lives on Tep::APP,
|
|
13
|
+
# which is per-worker under prefork. A bot redeeming a code MUST
|
|
14
|
+
# do so against the same worker that issued it. For most apps
|
|
15
|
+
# that's invisible (one human, one worker handling both the
|
|
16
|
+
# consent submission and the immediate redirect-then-redeem
|
|
17
|
+
# sequence), but high-fanout production setups will want
|
|
18
|
+
# cross-worker code storage (PG-backed) -- a future battery
|
|
19
|
+
# extension.
|
|
20
|
+
module Tep
|
|
21
|
+
class AuthOAuth2Code
|
|
22
|
+
attr_reader :code # opaque base64url string
|
|
23
|
+
attr_reader :principal_id # the human granting access
|
|
24
|
+
attr_reader :client_id # which client this code was issued for
|
|
25
|
+
attr_reader :caps_str # comma-separated symbols (granted subset)
|
|
26
|
+
attr_reader :expires_at # unix epoch seconds; >= now means alive
|
|
27
|
+
|
|
28
|
+
def initialize(code, principal_id, client_id, caps_str, expires_at)
|
|
29
|
+
@code = code
|
|
30
|
+
@principal_id = principal_id
|
|
31
|
+
@client_id = client_id
|
|
32
|
+
@caps_str = caps_str
|
|
33
|
+
@expires_at = expires_at
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def expired?(now)
|
|
37
|
+
now >= @expires_at
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Tep::AuthSessionCookie -- the SessionCookie provider for the Auth
|
|
2
|
+
# battery. Reads identity fields off the signed session cookie that
|
|
3
|
+
# Tep::Session already round-trips through Tep::App#dispatch.
|
|
4
|
+
#
|
|
5
|
+
# Configuration:
|
|
6
|
+
#
|
|
7
|
+
# Tep.session_secret = ENV["TEP_SESSION_SECRET"]
|
|
8
|
+
# Tep::Auth.install! # enables both Bearer + SessionCookie
|
|
9
|
+
#
|
|
10
|
+
# Identity in the session is stored as four keys (identity_sub /
|
|
11
|
+
# identity_caps / identity_delegate / identity_exp). The whole
|
|
12
|
+
# cookie is HMAC-signed (Tep::Session's existing payload+sig
|
|
13
|
+
# format), so forgery requires the secret. The identity payload IS
|
|
14
|
+
# visible to the client -- the cookie is signed, not encrypted --
|
|
15
|
+
# so don't put secrets in caps or in the delegate fields. Standard
|
|
16
|
+
# session-cookie tradeoff.
|
|
17
|
+
#
|
|
18
|
+
# Login / logout:
|
|
19
|
+
#
|
|
20
|
+
# post '/login' do
|
|
21
|
+
# # ... verify the user's password / OAuth handshake / etc ...
|
|
22
|
+
# ident = Tep::Identity.new("user:42", nil, [:read, :write])
|
|
23
|
+
# Tep::AuthSessionCookie.set(req, ident)
|
|
24
|
+
# # The session will be re-signed + emitted via Set-Cookie by
|
|
25
|
+
# # tep's normal session lifecycle (App#dispatch end).
|
|
26
|
+
# ""
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# post '/logout' do
|
|
30
|
+
# Tep::AuthSessionCookie.clear(req)
|
|
31
|
+
# ""
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# Provider-chain order: tried AFTER Tep::AuthBearerToken in
|
|
35
|
+
# Tep::Auth.identify. Bearer wins if both present, on the
|
|
36
|
+
# principle that an explicit Authorization header is a stronger
|
|
37
|
+
# signal of caller intent than a passively-replayed cookie.
|
|
38
|
+
#
|
|
39
|
+
# Flat namespacing (Tep::AuthSessionCookie, not
|
|
40
|
+
# Tep::Auth::SessionCookie) mirrors Tep::AuthBearerToken for the
|
|
41
|
+
# same spinel cls_id reasons -- see memory note
|
|
42
|
+
# [[spinel_widening_dispatch]].
|
|
43
|
+
module Tep
|
|
44
|
+
class AuthSessionCookie
|
|
45
|
+
# Write an Identity into req.session. Caller is responsible for
|
|
46
|
+
# ensuring Tep.session_secret is configured -- otherwise the
|
|
47
|
+
# response cookie won't get signed and the next request can't
|
|
48
|
+
# round-trip the identity back.
|
|
49
|
+
#
|
|
50
|
+
# `exp` is unix epoch seconds; nil disables expiry (the cookie
|
|
51
|
+
# itself still expires per its own Max-Age / Expires headers
|
|
52
|
+
# or browser session lifetime).
|
|
53
|
+
def self.set(req, identity, exp)
|
|
54
|
+
req.session.set("identity_sub", identity.principal_id)
|
|
55
|
+
req.session.set("identity_caps",
|
|
56
|
+
Tep::AuthSessionCookie.format_caps(identity.capabilities))
|
|
57
|
+
delegate = identity.acting_via
|
|
58
|
+
if delegate == nil
|
|
59
|
+
req.session.set("identity_delegate", "")
|
|
60
|
+
else
|
|
61
|
+
req.session.set("identity_delegate",
|
|
62
|
+
Tep::AuthSessionCookie.format_delegate(delegate))
|
|
63
|
+
end
|
|
64
|
+
if exp > 0
|
|
65
|
+
req.session.set("identity_exp", exp.to_s)
|
|
66
|
+
else
|
|
67
|
+
req.session.set("identity_exp", "")
|
|
68
|
+
end
|
|
69
|
+
0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Drop the identity fields from req.session. The session itself
|
|
73
|
+
# stays valid (signed cookie continues to round-trip), but any
|
|
74
|
+
# subsequent try() returns nil because identity_sub is empty.
|
|
75
|
+
def self.clear(req)
|
|
76
|
+
req.session.set("identity_sub", "")
|
|
77
|
+
req.session.set("identity_caps", "")
|
|
78
|
+
req.session.set("identity_delegate", "")
|
|
79
|
+
req.session.set("identity_exp", "")
|
|
80
|
+
0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Attempt to recover an Identity from req.session. Returns nil
|
|
84
|
+
# if the session has no identity (no prior #set call, or after
|
|
85
|
+
# #clear) or the stored identity is expired.
|
|
86
|
+
def self.try(req)
|
|
87
|
+
sub = req.session.get("identity_sub")
|
|
88
|
+
if sub.length == 0
|
|
89
|
+
return nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
exp_str = req.session.get("identity_exp")
|
|
93
|
+
if exp_str.length > 0
|
|
94
|
+
exp = exp_str.to_i
|
|
95
|
+
if exp > 0 && Time.now.to_i >= exp
|
|
96
|
+
return nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
caps_str = req.session.get("identity_caps")
|
|
101
|
+
caps = Tep::AuthBearerToken.parse_caps(caps_str)
|
|
102
|
+
|
|
103
|
+
delegate_str = req.session.get("identity_delegate")
|
|
104
|
+
delegation = Tep::AuthBearerToken.parse_delegate(delegate_str)
|
|
105
|
+
|
|
106
|
+
Tep::Identity.new(sub, delegation, caps)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# [:read, :write, :post_summary] -> "read,write,post_summary"
|
|
110
|
+
def self.format_caps(caps)
|
|
111
|
+
out = ""
|
|
112
|
+
first = true
|
|
113
|
+
caps.each do |c|
|
|
114
|
+
if !first
|
|
115
|
+
out = out + ","
|
|
116
|
+
end
|
|
117
|
+
out = out + c.to_s
|
|
118
|
+
first = false
|
|
119
|
+
end
|
|
120
|
+
out
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# AgentDelegation -> "agent_id|issued_at|expires_at|origin".
|
|
124
|
+
# Inverse of Tep::AuthBearerToken.parse_delegate.
|
|
125
|
+
def self.format_delegate(deleg)
|
|
126
|
+
deleg.agent_id + "|" +
|
|
127
|
+
deleg.issued_at.to_s + "|" +
|
|
128
|
+
deleg.expires_at.to_s + "|" +
|
|
129
|
+
deleg.origin.to_s
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|