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/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