ruby_everywhere 0.3.0 → 0.4.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/everywhere/bridge.js +60 -3
  4. data/lib/everywhere/android_resources.rb +217 -0
  5. data/lib/everywhere/android_sdk.rb +330 -0
  6. data/lib/everywhere/asset_catalog.rb +185 -0
  7. data/lib/everywhere/auth_handoff.rb +398 -0
  8. data/lib/everywhere/auth_token.rb +118 -0
  9. data/lib/everywhere/builders/android.rb +844 -0
  10. data/lib/everywhere/builders/ios.rb +40 -3
  11. data/lib/everywhere/commands/build.rb +51 -9
  12. data/lib/everywhere/commands/clean.rb +10 -5
  13. data/lib/everywhere/commands/dev.rb +46 -8
  14. data/lib/everywhere/commands/doctor.rb +89 -3
  15. data/lib/everywhere/commands/install.rb +1 -1
  16. data/lib/everywhere/commands/logs.rb +117 -8
  17. data/lib/everywhere/config.rb +409 -13
  18. data/lib/everywhere/emulator.rb +405 -0
  19. data/lib/everywhere/engine.rb +12 -0
  20. data/lib/everywhere/icon.rb +196 -0
  21. data/lib/everywhere/log_filter.rb +74 -0
  22. data/lib/everywhere/native_helper.rb +2 -5
  23. data/lib/everywhere/omniauth.rb +88 -0
  24. data/lib/everywhere/paths.rb +47 -0
  25. data/lib/everywhere/version.rb +1 -1
  26. data/support/mobile/android/README.md +217 -0
  27. data/support/mobile/android/app/build.gradle.kts +156 -0
  28. data/support/mobile/android/app/everywhere.properties +8 -0
  29. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  30. data/support/mobile/android/app/proguard-rules.pro +21 -0
  31. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  32. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  33. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  34. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  35. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  36. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  37. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  38. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  39. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  40. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  41. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  55. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  56. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  57. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  58. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  59. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  60. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  61. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  62. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  63. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  64. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  65. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  66. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  67. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  68. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  69. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  70. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  71. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  72. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  73. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  74. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  75. data/support/mobile/android/build.gradle.kts +8 -0
  76. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  77. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  78. data/support/mobile/android/gradle.properties +14 -0
  79. data/support/mobile/android/gradlew +251 -0
  80. data/support/mobile/android/gradlew.bat +94 -0
  81. data/support/mobile/android/settings.gradle.kts +28 -0
  82. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  83. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  84. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  85. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  86. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  87. data/support/mobile/ios/README.md +4 -1
  88. metadata +67 -1
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+ require "time"
5
+ require_relative "auth_token"
6
+ require_relative "config"
7
+
8
+ module Everywhere
9
+ # Third-party sign-in for the native shells, as Rack middleware.
10
+ #
11
+ # Google refuses OAuth in an embedded web view outright, and Apple and GitHub
12
+ # are only marginally better there (no password manager, no already-signed-in
13
+ # session, 2FA that fights the keyboard). So the shell doesn't use its web
14
+ # view: it hands provider paths to an ASWebAuthenticationSession — the
15
+ # system's own browser, sharing Safari's cookies — and this middleware bridges
16
+ # the two jars, because the session cookie the flow ends with lands in the
17
+ # browser's jar and the app's web view can never read it.
18
+ #
19
+ # 1. shell opens /everywhere/auth/start?to=/auth/github (auth browser)
20
+ # → marker cookie, redirect to the app's own provider path
21
+ # 2. the app's normal OAuth runs — OmniAuth, or whatever it uses — and
22
+ # redirects somewhere signed in
23
+ # 3. this middleware catches that redirect, seals the resulting cookie jar
24
+ # into a short-lived token, and redirects to <scheme>://auth?token=…,
25
+ # which is the callback ASWebAuthenticationSession is waiting for
26
+ # 4. the shell visits /everywhere/auth/handoff?token=… in its web view,
27
+ # which replays the jar there and lands on the app's post-auth page
28
+ #
29
+ # The app's auth is untouched: nothing here knows a provider from a hole in
30
+ # the ground, only which paths `auth.oauth_paths` says start a flow.
31
+ #
32
+ # Rails apps get this from Everywhere::Engine (at the top of the middleware
33
+ # stack, above any OmniAuth-family middleware). Sinatra/Hanami add it in
34
+ # config.ru — first, for the same reason — alongside the config endpoint:
35
+ #
36
+ # use Everywhere::AuthHandoff
37
+ # use Everywhere::MobileConfigEndpoint
38
+ #
39
+ # Apps on OmniAuth 2 also need Everywhere::OmniAuth.protect! — see
40
+ # everywhere/omniauth.rb for why its POST-only request phase and this flow
41
+ # have to be introduced to each other.
42
+ class AuthHandoff
43
+ START = "/everywhere/auth/start"
44
+ NATIVE = "/everywhere/auth/native"
45
+ HANDOFF = "/everywhere/auth/handoff"
46
+
47
+ # Set by START, read on the way back out. Ours, not the app's session: it
48
+ # must survive whatever the app does to its own session mid-flow, and it
49
+ # must be droppable from the jar we replay.
50
+ MARKER_COOKIE = "_everywhere_auth"
51
+ MARKER_PURPOSE = "everywhere/auth-marker"
52
+ HANDOFF_PURPOSE = "everywhere/auth-handoff"
53
+
54
+ # How long a started flow stays interceptable. Long enough for a password
55
+ # manager, a 2FA code and a slow provider; short enough that an abandoned
56
+ # flow can't divert an ordinary browser session later.
57
+ MARKER_TTL = 900
58
+
59
+ def initialize(app, root: Dir.pwd)
60
+ @app = app
61
+ @root = root
62
+ @mutex = Mutex.new
63
+ end
64
+
65
+ def call(env)
66
+ config = config_for(env)
67
+ return @app.call(env) unless config&.oauth?
68
+
69
+ case env["PATH_INFO"]
70
+ when START then return start(env, config)
71
+ when NATIVE then return native_page(env)
72
+ when HANDOFF then return handoff(env, config)
73
+ end
74
+
75
+ divert(env, config) || finish(env, config)
76
+ end
77
+
78
+ private
79
+
80
+ # GET /everywhere/auth/start?to=/auth/github&return_to=/settings
81
+ #
82
+ # The first page of the auth browser. Marks the jar as ours and continues
83
+ # into the app's own provider path — `to` is only honored when it's a path
84
+ # the config already calls a provider path, so this can't be turned into an
85
+ # open redirect.
86
+ def start(env, config)
87
+ return redirect("/") if cross_site?(env)
88
+
89
+ params = query(env)
90
+ to = same_origin_path(params["to"], env)
91
+ return redirect("/") unless to && config.oauth_path?(to.split("?").first)
92
+
93
+ marker = AuthToken.encrypt({ "return_to" => same_origin_path(params["return_to"], env) || "/",
94
+ "iat" => Time.now.to_i },
95
+ purpose: MARKER_PURPOSE, ttl: MARKER_TTL)
96
+ redirect(to, cookies: [marker_cookie(marker, env, max_age: MARKER_TTL)])
97
+ end
98
+
99
+ # A cross-site visit must not seed a sign-in: a fresh marker is what the
100
+ # OmniAuth request validator (everywhere/omniauth.rb) accepts a GET request
101
+ # phase on, so start would otherwise be the CSRF hole POST-only request
102
+ # phases closed. The shell opens this URL itself — browsers stamp that
103
+ # Sec-Fetch-Site: none — and only an explicit cross-site initiator or a
104
+ # subresource load is refused, so browsers too old for the headers lose
105
+ # nothing.
106
+ def cross_site?(env)
107
+ return true if env["HTTP_SEC_FETCH_SITE"] == "cross-site"
108
+
109
+ dest = env["HTTP_SEC_FETCH_DEST"]
110
+ !dest.nil? && dest != "document"
111
+ end
112
+
113
+ # GET /everywhere/auth/native?to=/auth/github
114
+ #
115
+ # The page that asks the shell to open the auth session. Only reached when
116
+ # the shell couldn't divert the visit itself (see #divert).
117
+ def native_page(env)
118
+ html(Everywhere.mobile_auth_html(query(env)["to"]))
119
+ end
120
+
121
+ # GET /everywhere/auth/handoff?token=…
122
+ #
123
+ # Visited in the app's web view: replays the sealed jar there, then sends
124
+ # the app on to wherever its own sign-in wanted to go. A token that doesn't
125
+ # verify lands on the root signed out rather than erroring — by then the
126
+ # user has done everything right, and the honest answer is the app.
127
+ def handoff(env, config)
128
+ payload = AuthToken.decrypt(query(env)["token"], purpose: HANDOFF_PURPOSE, once: true)
129
+ return redirect("/") unless payload
130
+
131
+ jar = payload["cookies"]
132
+ expiries = payload["expires"].is_a?(Hash) ? payload["expires"] : {}
133
+ cookies = jar.is_a?(Hash) ? jar.filter_map { |name, value| replayed_cookie(name, value, expiries[name], env) } : []
134
+ redirect(same_origin_path(payload["to"], env) || "/", cookies: cookies)
135
+ end
136
+
137
+ # One cookie on its way into the web view's jar. A lifetime the flow saw
138
+ # (Set-Cookie Max-Age/Expires) carries over — a remember-me cookie replayed
139
+ # session-scoped would sign the user out at the next app launch. Cookies
140
+ # the flow only ever saw in a request header stay session-scoped: their
141
+ # lifetime is unknowable from there.
142
+ def replayed_cookie(name, value, expires_at, env)
143
+ return cookie(name, value, env) unless expires_at
144
+
145
+ remaining = expires_at.to_i - Time.now.to_i
146
+ return nil unless remaining.positive?
147
+
148
+ cookie(name, value, env, max_age: remaining)
149
+ end
150
+
151
+ # A provider path requested from inside the shell's web view — the visits
152
+ # the shell couldn't intercept natively (a `data-turbo="false"` link, or the
153
+ # POST that OmniAuth 2 requires). Bounce to a page that can ask it to.
154
+ def divert(env, config)
155
+ return nil unless Everywhere.native_platform_of(env["HTTP_USER_AGENT"])
156
+ return nil unless config.oauth_path?(env["PATH_INFO"])
157
+
158
+ redirect("#{NATIVE}?#{Rack::Utils.build_query("to" => path_with_query(env))}")
159
+ end
160
+
161
+ # The flow is over the moment it navigates back out of the provider paths:
162
+ # the app's callback has redirected somewhere signed in, and the browser is
163
+ # asking for that page. That's the request we take.
164
+ #
165
+ # One hop later than the callback's own redirect, deliberately. The cookies
166
+ # are the whole point of this, and by here they're in the request — where
167
+ # every framework agrees they are. In the callback's *response* they may not
168
+ # be yet: Rails writes Set-Cookie in ActionDispatch::Cookies, and whether
169
+ # this middleware sees that depends on where the app put it in the stack —
170
+ # a thing the flow must not be sensitive to.
171
+ def finish(env, config)
172
+ # Only ever the auth browser: inside the web view the app is already
173
+ # holding the jar we would be handing it, and a stale marker there must
174
+ # not divert an ordinary visit into a callback URL it can't open.
175
+ marker = auth_marker(env) unless Everywhere.native_platform_of(env["HTTP_USER_AGENT"])
176
+ return @app.call(env) if marker.nil? || config.oauth_path?(env["PATH_INFO"]) || !navigation?(env)
177
+
178
+ _, headers, body = @app.call(env)
179
+ body.close if body.respond_to?(:close) # dropped: the browser gets the callback instead
180
+
181
+ close(env, config, headers, to: path_with_query(env), return_to: marker["return_to"])
182
+ end
183
+
184
+ # A top-level page load, as opposed to the stylesheet a provider's own
185
+ # consent screen pulls in — same-origin for us only by accident, and not
186
+ # the user arriving back signed in.
187
+ def navigation?(env)
188
+ env["REQUEST_METHOD"] == "GET" && env["HTTP_ACCEPT"].to_s.include?("text/html")
189
+ end
190
+
191
+ # Seal what the flow ended up with and return through the callback scheme.
192
+ # The jar is captured raw — verbatim values, straight from the request's
193
+ # Cookie header and any Set-Cookie on the way past — so whatever the app's
194
+ # auth library signed or encrypted arrives in the web view byte for byte.
195
+ def close(env, config, headers, to:, return_to:)
196
+ jar = request_jar(env)
197
+ expiries = apply_set_cookies(jar, headers)
198
+ jar.delete(MARKER_COOKIE)
199
+ jar.select! { |name, _| config.auth_cookie?(name) }
200
+ expiries.select! { |name, _| jar.key?(name) }
201
+
202
+ # The app's destination wins, except where it has no opinion: a sign-in
203
+ # that lands on the root usually meant "wherever you were", and in the
204
+ # auth browser the app has no stored location to remember that from.
205
+ target = to == "/" && return_to ? return_to : to
206
+ payload = { "cookies" => jar, "to" => target }
207
+ payload["expires"] = expiries unless expiries.empty?
208
+ token = AuthToken.encrypt(payload, purpose: HANDOFF_PURPOSE, ttl: config.auth_token_ttl)
209
+
210
+ # The browser jar keeps what it earned (the user is signed in in Safari
211
+ # too, as with any ASWebAuthenticationSession) minus our marker.
212
+ cookies = Array(header_values(headers, "set-cookie")) + [marker_cookie("", env, max_age: 0)]
213
+ redirect("#{config.auth_scheme}://auth?#{Rack::Utils.build_query("token" => token)}", cookies: cookies)
214
+ end
215
+
216
+ # MARK: config
217
+
218
+ # everywhere.yml, re-read only when it changes on disk. This middleware sees
219
+ # every request in the app, so the config endpoint's load-per-request would
220
+ # be a YAML parse in the way of each one.
221
+ #
222
+ # A save that doesn't parse must not take the site down from inside a
223
+ # middleware that wraps every request: keep the last good config — or none,
224
+ # on a first load — and say so, once per bad save.
225
+ def config_for(_env)
226
+ path = File.join(@root, Config::FILE)
227
+ mtime = File.mtime(path) if File.exist?(path)
228
+ return @config if defined?(@config) && @mtime == mtime
229
+
230
+ @mutex.synchronize do
231
+ return @config if defined?(@config) && @mtime == mtime
232
+
233
+ loaded = begin
234
+ Config.load(@root)
235
+ rescue StandardError => e
236
+ kept = defined?(@config) && @config ? "keeping the previous configuration" : "native sign-in is off until it loads"
237
+ warn "everywhere: #{Config::FILE} could not be loaded (#{e.message}); #{kept}"
238
+ defined?(@config) ? @config : nil
239
+ end
240
+ @mtime = mtime
241
+ @config = loaded
242
+ end
243
+ end
244
+
245
+ # MARK: request/response plumbing
246
+
247
+ def query(env) = Rack::Utils.parse_query(env["QUERY_STRING"])
248
+
249
+ def path_with_query(env)
250
+ query = env["QUERY_STRING"].to_s
251
+ query.empty? ? env["PATH_INFO"] : "#{env["PATH_INFO"]}?#{query}"
252
+ end
253
+
254
+ def auth_marker(env)
255
+ self.class.marker(env)
256
+ end
257
+
258
+ def redirect(location, cookies: [])
259
+ headers = { "location" => location, "cache-control" => "no-store", "content-type" => "text/html" }
260
+ headers = set_cookie(headers, cookies)
261
+ [302, canonical(headers), ["<html><body>Redirecting…</body></html>"]]
262
+ end
263
+
264
+ def html(body)
265
+ [200, canonical({ "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store" }), [body]]
266
+ end
267
+
268
+ # Rack 3 requires lowercase header names; Rack 2's ecosystem looks names up
269
+ # in the classic case (servers are forgiving, middlewares are not).
270
+ def canonical(headers)
271
+ return headers if rack3?
272
+
273
+ headers.transform_keys { |name| name.split("-").map(&:capitalize).join("-") }
274
+ end
275
+
276
+ # Rack 3 wants set-cookie as an array; Rack 2 wants one newline-joined
277
+ # string. Both are the same list of cookie lines either way.
278
+ def set_cookie(headers, cookies)
279
+ values = Array(cookies).reject { |c| c.to_s.empty? }
280
+ return headers if values.empty?
281
+
282
+ headers.merge("set-cookie" => rack3? ? values : values.join("\n"))
283
+ end
284
+
285
+ # Rack::Headers arrived with Rack 3 — and version constants aren't loaded by
286
+ # rack/utils alone, so the class is the reliable tell.
287
+ def rack3? = defined?(Rack::Headers) ? true : false
288
+
289
+ def header_values(headers, name)
290
+ value = headers[name] || headers[name.split("-").map(&:capitalize).join("-")]
291
+ value.is_a?(String) ? value.split("\n") : value
292
+ end
293
+
294
+ # A same-origin path (with query), or nil. Absolute URLs are accepted only
295
+ # for the app's own host — everything here ends up in a Location header or
296
+ # a redirect target.
297
+ def same_origin_path(value, env)
298
+ path = value.to_s
299
+ return nil if path.empty?
300
+
301
+ if (match = path.match(%r{\Ahttps?://([^/]+)(/.*)?\z}i))
302
+ return nil unless match[1].casecmp?(env["HTTP_HOST"].to_s)
303
+
304
+ path = match[2].to_s
305
+ path = "/" if path.empty?
306
+ end
307
+ path if path.start_with?("/") && !path.start_with?("//")
308
+ end
309
+
310
+ # MARK: cookies
311
+
312
+ def request_jar(env)
313
+ self.class.request_jar(env)
314
+ end
315
+
316
+ class << self
317
+ # The request's cookies, values left exactly as they arrived — re-escaping
318
+ # a decoded value would corrupt any cookie the app didn't escape the same
319
+ # way. First occurrence wins on a duplicate name, matching Rack's parser
320
+ # (browsers send the most specific cookie first), so the jar sealed here
321
+ # is the jar the app actually read.
322
+ def request_jar(env)
323
+ env["HTTP_COOKIE"].to_s.split(/;\s*/).each_with_object({}) do |pair, jar|
324
+ name, value = pair.split("=", 2)
325
+ next if name.nil? || value.nil?
326
+
327
+ name = name.strip
328
+ jar[name] = value unless jar.key?(name)
329
+ end
330
+ end
331
+
332
+ # The start marker riding a request, decrypted, or nil. Class-level
333
+ # because the OmniAuth request validator (everywhere/omniauth.rb) asks
334
+ # the same question of the same cookie.
335
+ def marker(env)
336
+ raw = request_jar(env)[MARKER_COOKIE] or return nil
337
+
338
+ AuthToken.decrypt(Rack::Utils.unescape(raw), purpose: MARKER_PURPOSE)
339
+ end
340
+ end
341
+
342
+ # Fold a response's Set-Cookie headers into the jar, honoring deletions —
343
+ # a sign-out mid-flow must not hand the app a session it just discarded.
344
+ # Returns when each surviving cookie expires ({name => unix time}, only
345
+ # the ones that said): the response is the flow's one chance to learn a
346
+ # lifetime, and what keeps a remember-me cookie from crossing into the
347
+ # web view session-scoped.
348
+ def apply_set_cookies(jar, headers)
349
+ expiries = {}
350
+ Array(header_values(headers, "set-cookie")).each do |line|
351
+ pair, *attributes = line.to_s.split(/;\s*/)
352
+ name, value = pair.to_s.split("=", 2)
353
+ next if name.nil? || value.nil?
354
+
355
+ name = name.strip
356
+ expiry = expiry_of(attributes)
357
+ if value.empty? || (expiry && expiry <= Time.now.to_i)
358
+ jar.delete(name)
359
+ expiries.delete(name)
360
+ else
361
+ jar[name] = value
362
+ expiries[name] = expiry if expiry
363
+ end
364
+ end
365
+ expiries
366
+ end
367
+
368
+ # When the cookie line says it expires (unix time), nil for session scope.
369
+ # Max-Age wins over Expires, per RFC 6265.
370
+ def expiry_of(attributes)
371
+ expires = nil
372
+ attributes.each do |attribute|
373
+ key, value = attribute.split("=", 2)
374
+ case key.to_s.downcase
375
+ when "max-age" then return Time.now.to_i + value.to_i
376
+ when "expires" then expires = (Time.httpdate(value.to_s).to_i rescue nil)
377
+ end
378
+ end
379
+ expires
380
+ end
381
+
382
+ def marker_cookie(value, env, max_age:)
383
+ cookie(MARKER_COOKIE, value, env, max_age: max_age)
384
+ end
385
+
386
+ # Rebuilt rather than replayed attribute-for-attribute: a request's Cookie
387
+ # header carries names and values only, so the flags are ours to choose.
388
+ # HttpOnly across the board is the right default for an auth jar — a cookie
389
+ # the app's own JavaScript reads is not one it should be signing in with.
390
+ def cookie(name, value, env, max_age: nil)
391
+ parts = ["#{name}=#{value}", "path=/", "HttpOnly", "SameSite=Lax"]
392
+ parts << "secure" if env["rack.url_scheme"] == "https" || env["HTTPS"] == "on"
393
+ parts << "max-age=#{max_age}" if max_age
394
+ parts << "expires=#{Time.at(0).httpdate}" if max_age&.zero?
395
+ parts.join("; ")
396
+ end
397
+ end
398
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module Everywhere
8
+ # The sealed envelopes the native auth flow passes around: the marker cookie
9
+ # that says "this browser session is completing a sign-in for the app", and
10
+ # the handoff token that carries the resulting cookie jar back into the app's
11
+ # web view.
12
+ #
13
+ # Self-contained (encrypt-then-authenticate, expiry inside the payload) rather
14
+ # than a server-side store: the flow crosses processes — and in a load-balanced
15
+ # deploy, hosts — between the auth browser and the web view, and a shared
16
+ # store would be one more thing to configure. AES-256-GCM with the purpose as
17
+ # associated data, so a marker can never be replayed as a handoff token.
18
+ module AuthToken
19
+ CIPHER = "aes-256-gcm"
20
+ NONCE_BYTES = 12
21
+ TAG_BYTES = 16
22
+
23
+ class MissingSecret < StandardError; end
24
+
25
+ class << self
26
+ # The secret the tokens are keyed from. Set explicitly, or left to be
27
+ # discovered: the Rails app's secret_key_base, else SECRET_KEY_BASE /
28
+ # EVERYWHERE_AUTH_SECRET from the environment (Sinatra/Hanami).
29
+ attr_writer :secret
30
+
31
+ def secret
32
+ @secret ||= discover_secret or raise(MissingSecret, <<~MESSAGE)
33
+ Native sign-in needs a secret to seal its handoff tokens. Rails apps use
34
+ secret_key_base automatically; elsewhere set SECRET_KEY_BASE (or
35
+ EVERYWHERE_AUTH_SECRET) in the environment, or assign
36
+ Everywhere::AuthToken.secret at boot.
37
+ MESSAGE
38
+ end
39
+
40
+ def encrypt(payload, purpose:, ttl:)
41
+ sealed = JSON.generate(payload.merge("exp" => now + ttl))
42
+ cipher = OpenSSL::Cipher.new(CIPHER).encrypt
43
+ cipher.key = key
44
+ nonce = cipher.random_iv
45
+ cipher.auth_data = purpose.to_s
46
+ ciphertext = cipher.update(sealed) + cipher.final
47
+ Base64.urlsafe_encode64(nonce + cipher.auth_tag + ciphertext, padding: false)
48
+ end
49
+
50
+ # The payload, or nil for anything we can't fully vouch for: tampered,
51
+ # wrong purpose, expired, or (when `once`) already spent. Never raises on
52
+ # bad input — every one of these arrives from outside.
53
+ def decrypt(token, purpose:, once: false)
54
+ raw = Base64.urlsafe_decode64(token.to_s)
55
+ return nil if raw.bytesize <= NONCE_BYTES + TAG_BYTES
56
+
57
+ decipher = OpenSSL::Cipher.new(CIPHER).decrypt
58
+ decipher.key = key
59
+ decipher.iv = raw.byteslice(0, NONCE_BYTES)
60
+ decipher.auth_tag = raw.byteslice(NONCE_BYTES, TAG_BYTES)
61
+ decipher.auth_data = purpose.to_s
62
+
63
+ payload = JSON.parse(decipher.update(raw.byteslice(NONCE_BYTES + TAG_BYTES..)) + decipher.final)
64
+ return nil unless payload.is_a?(Hash) && payload["exp"].to_i > now
65
+ return nil if once && !claim(token, payload["exp"].to_i)
66
+
67
+ payload
68
+ rescue ArgumentError, OpenSSL::Cipher::CipherError, JSON::ParserError
69
+ nil
70
+ end
71
+
72
+ # Test hook — a new secret means a new key.
73
+ def reset!
74
+ @secret = nil
75
+ @key = nil
76
+ @spent = nil
77
+ end
78
+
79
+ private
80
+
81
+ def key
82
+ @key ||= OpenSSL::Digest::SHA256.digest("ruby_everywhere/auth-token\0#{secret}")
83
+ end
84
+
85
+ def discover_secret
86
+ if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
87
+ base = ::Rails.application.secret_key_base
88
+ return base if base && !base.empty?
89
+ end
90
+ %w[SECRET_KEY_BASE EVERYWHERE_AUTH_SECRET].each do |name|
91
+ value = ENV[name]
92
+ return value if value && !value.empty?
93
+ end
94
+ nil
95
+ end
96
+
97
+ # Spend a token, returning false if it was already spent. In-process only:
98
+ # across workers the ceiling is the token's own (seconds-long) expiry,
99
+ # which is the guarantee that actually matters — the token never leaves
100
+ # the device, and a replay would only re-establish the session its owner
101
+ # already holds.
102
+ def claim(token, expires_at)
103
+ digest = OpenSSL::Digest::SHA256.hexdigest(token.to_s)
104
+ spent_mutex.synchronize do
105
+ spent.reject! { |_, exp| exp <= now }
106
+ return false if spent.key?(digest)
107
+
108
+ spent[digest] = expires_at
109
+ true
110
+ end
111
+ end
112
+
113
+ def spent = @spent ||= {}
114
+ def spent_mutex = @spent_mutex ||= Mutex.new
115
+ def now = Time.now.to_i
116
+ end
117
+ end
118
+ end