ruby_everywhere 0.3.0 → 0.5.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +339 -0
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +211 -3
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/android_resources.rb +217 -0
  7. data/lib/everywhere/android_sdk.rb +330 -0
  8. data/lib/everywhere/asset_catalog.rb +185 -0
  9. data/lib/everywhere/auth_handoff.rb +404 -0
  10. data/lib/everywhere/auth_token.rb +118 -0
  11. data/lib/everywhere/builders/android.rb +844 -0
  12. data/lib/everywhere/builders/desktop.rb +326 -0
  13. data/lib/everywhere/builders/ios.rb +40 -3
  14. data/lib/everywhere/child_processes.rb +74 -0
  15. data/lib/everywhere/commands/build.rb +95 -14
  16. data/lib/everywhere/commands/clean.rb +10 -5
  17. data/lib/everywhere/commands/dev.rb +462 -57
  18. data/lib/everywhere/commands/doctor.rb +89 -3
  19. data/lib/everywhere/commands/install.rb +21 -1
  20. data/lib/everywhere/commands/logs.rb +117 -8
  21. data/lib/everywhere/commands/release.rb +2 -2
  22. data/lib/everywhere/config.rb +703 -13
  23. data/lib/everywhere/console.rb +117 -0
  24. data/lib/everywhere/desktop_assets.rb +150 -0
  25. data/lib/everywhere/dock/footer.rb +150 -0
  26. data/lib/everywhere/dock/screen.rb +114 -0
  27. data/lib/everywhere/dock/state.rb +59 -0
  28. data/lib/everywhere/dock.rb +238 -0
  29. data/lib/everywhere/emulator.rb +405 -0
  30. data/lib/everywhere/engine.rb +12 -0
  31. data/lib/everywhere/fatal.rb +20 -0
  32. data/lib/everywhere/icon.rb +196 -0
  33. data/lib/everywhere/line_pump.rb +89 -0
  34. data/lib/everywhere/log_filter.rb +111 -0
  35. data/lib/everywhere/native_helper.rb +39 -9
  36. data/lib/everywhere/omniauth.rb +88 -0
  37. data/lib/everywhere/paths.rb +109 -11
  38. data/lib/everywhere/relay.rb +77 -0
  39. data/lib/everywhere/shellout.rb +90 -15
  40. data/lib/everywhere/task_pool.rb +123 -0
  41. data/lib/everywhere/ui.rb +54 -11
  42. data/lib/everywhere/version.rb +1 -1
  43. data/support/desktop/README.md +121 -0
  44. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  45. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  46. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  47. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  48. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  49. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  50. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  51. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  52. data/support/mobile/android/README.md +217 -0
  53. data/support/mobile/android/app/build.gradle.kts +156 -0
  54. data/support/mobile/android/app/everywhere.properties +8 -0
  55. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  56. data/support/mobile/android/app/proguard-rules.pro +21 -0
  57. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  58. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  59. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  60. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  61. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  62. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  63. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  64. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  65. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  66. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  67. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  68. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  69. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  70. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  71. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  72. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  73. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  74. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  75. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  76. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  77. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  78. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  79. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  80. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  81. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  82. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  83. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  84. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  85. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  86. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  87. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  88. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  89. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  90. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  91. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  92. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  93. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  94. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  95. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  96. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  97. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  98. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  99. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  100. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  101. data/support/mobile/android/build.gradle.kts +8 -0
  102. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  103. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  104. data/support/mobile/android/gradle.properties +14 -0
  105. data/support/mobile/android/gradlew +251 -0
  106. data/support/mobile/android/gradlew.bat +94 -0
  107. data/support/mobile/android/settings.gradle.kts +28 -0
  108. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  109. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  110. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  111. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  112. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  113. data/support/mobile/ios/README.md +4 -1
  114. data/support/{macos → release/macos}/notarize.sh +2 -2
  115. metadata +97 -17
  116. data/support/github/build.yml +0 -85
  117. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  118. /data/support/{shell → desktop}/splash/index.html +0 -0
  119. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  120. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  121. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  122. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  123. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  124. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  125. /data/support/{macos → release/macos}/entitlements.plist +0 -0
@@ -0,0 +1,404 @@
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
+ #
155
+ # mobile_platform_of, not native_platform_of: the whole handoff exists
156
+ # because a phone shell must open the provider in ASWebAuthenticationSession
157
+ # / Custom Tabs. The desktop shell already sends external links to the system
158
+ # browser, so diverting there would bounce sign-in to a page nothing can
159
+ # complete.
160
+ def divert(env, config)
161
+ return nil unless Everywhere.mobile_platform_of(env["HTTP_USER_AGENT"])
162
+ return nil unless config.oauth_path?(env["PATH_INFO"])
163
+
164
+ redirect("#{NATIVE}?#{Rack::Utils.build_query("to" => path_with_query(env))}")
165
+ end
166
+
167
+ # The flow is over the moment it navigates back out of the provider paths:
168
+ # the app's callback has redirected somewhere signed in, and the browser is
169
+ # asking for that page. That's the request we take.
170
+ #
171
+ # One hop later than the callback's own redirect, deliberately. The cookies
172
+ # are the whole point of this, and by here they're in the request — where
173
+ # every framework agrees they are. In the callback's *response* they may not
174
+ # be yet: Rails writes Set-Cookie in ActionDispatch::Cookies, and whether
175
+ # this middleware sees that depends on where the app put it in the stack —
176
+ # a thing the flow must not be sensitive to.
177
+ def finish(env, config)
178
+ # Only ever the auth browser: inside the web view the app is already
179
+ # holding the jar we would be handing it, and a stale marker there must
180
+ # not divert an ordinary visit into a callback URL it can't open.
181
+ marker = auth_marker(env) unless Everywhere.mobile_platform_of(env["HTTP_USER_AGENT"])
182
+ return @app.call(env) if marker.nil? || config.oauth_path?(env["PATH_INFO"]) || !navigation?(env)
183
+
184
+ _, headers, body = @app.call(env)
185
+ body.close if body.respond_to?(:close) # dropped: the browser gets the callback instead
186
+
187
+ close(env, config, headers, to: path_with_query(env), return_to: marker["return_to"])
188
+ end
189
+
190
+ # A top-level page load, as opposed to the stylesheet a provider's own
191
+ # consent screen pulls in — same-origin for us only by accident, and not
192
+ # the user arriving back signed in.
193
+ def navigation?(env)
194
+ env["REQUEST_METHOD"] == "GET" && env["HTTP_ACCEPT"].to_s.include?("text/html")
195
+ end
196
+
197
+ # Seal what the flow ended up with and return through the callback scheme.
198
+ # The jar is captured raw — verbatim values, straight from the request's
199
+ # Cookie header and any Set-Cookie on the way past — so whatever the app's
200
+ # auth library signed or encrypted arrives in the web view byte for byte.
201
+ def close(env, config, headers, to:, return_to:)
202
+ jar = request_jar(env)
203
+ expiries = apply_set_cookies(jar, headers)
204
+ jar.delete(MARKER_COOKIE)
205
+ jar.select! { |name, _| config.auth_cookie?(name) }
206
+ expiries.select! { |name, _| jar.key?(name) }
207
+
208
+ # The app's destination wins, except where it has no opinion: a sign-in
209
+ # that lands on the root usually meant "wherever you were", and in the
210
+ # auth browser the app has no stored location to remember that from.
211
+ target = to == "/" && return_to ? return_to : to
212
+ payload = { "cookies" => jar, "to" => target }
213
+ payload["expires"] = expiries unless expiries.empty?
214
+ token = AuthToken.encrypt(payload, purpose: HANDOFF_PURPOSE, ttl: config.auth_token_ttl)
215
+
216
+ # The browser jar keeps what it earned (the user is signed in in Safari
217
+ # too, as with any ASWebAuthenticationSession) minus our marker.
218
+ cookies = Array(header_values(headers, "set-cookie")) + [marker_cookie("", env, max_age: 0)]
219
+ redirect("#{config.auth_scheme}://auth?#{Rack::Utils.build_query("token" => token)}", cookies: cookies)
220
+ end
221
+
222
+ # MARK: config
223
+
224
+ # everywhere.yml, re-read only when it changes on disk. This middleware sees
225
+ # every request in the app, so the config endpoint's load-per-request would
226
+ # be a YAML parse in the way of each one.
227
+ #
228
+ # A save that doesn't parse must not take the site down from inside a
229
+ # middleware that wraps every request: keep the last good config — or none,
230
+ # on a first load — and say so, once per bad save.
231
+ def config_for(_env)
232
+ path = File.join(@root, Config::FILE)
233
+ mtime = File.mtime(path) if File.exist?(path)
234
+ return @config if defined?(@config) && @mtime == mtime
235
+
236
+ @mutex.synchronize do
237
+ return @config if defined?(@config) && @mtime == mtime
238
+
239
+ loaded = begin
240
+ Config.load(@root)
241
+ rescue StandardError => e
242
+ kept = defined?(@config) && @config ? "keeping the previous configuration" : "native sign-in is off until it loads"
243
+ warn "everywhere: #{Config::FILE} could not be loaded (#{e.message}); #{kept}"
244
+ defined?(@config) ? @config : nil
245
+ end
246
+ @mtime = mtime
247
+ @config = loaded
248
+ end
249
+ end
250
+
251
+ # MARK: request/response plumbing
252
+
253
+ def query(env) = Rack::Utils.parse_query(env["QUERY_STRING"])
254
+
255
+ def path_with_query(env)
256
+ query = env["QUERY_STRING"].to_s
257
+ query.empty? ? env["PATH_INFO"] : "#{env["PATH_INFO"]}?#{query}"
258
+ end
259
+
260
+ def auth_marker(env)
261
+ self.class.marker(env)
262
+ end
263
+
264
+ def redirect(location, cookies: [])
265
+ headers = { "location" => location, "cache-control" => "no-store", "content-type" => "text/html" }
266
+ headers = set_cookie(headers, cookies)
267
+ [302, canonical(headers), ["<html><body>Redirecting…</body></html>"]]
268
+ end
269
+
270
+ def html(body)
271
+ [200, canonical({ "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store" }), [body]]
272
+ end
273
+
274
+ # Rack 3 requires lowercase header names; Rack 2's ecosystem looks names up
275
+ # in the classic case (servers are forgiving, middlewares are not).
276
+ def canonical(headers)
277
+ return headers if rack3?
278
+
279
+ headers.transform_keys { |name| name.split("-").map(&:capitalize).join("-") }
280
+ end
281
+
282
+ # Rack 3 wants set-cookie as an array; Rack 2 wants one newline-joined
283
+ # string. Both are the same list of cookie lines either way.
284
+ def set_cookie(headers, cookies)
285
+ values = Array(cookies).reject { |c| c.to_s.empty? }
286
+ return headers if values.empty?
287
+
288
+ headers.merge("set-cookie" => rack3? ? values : values.join("\n"))
289
+ end
290
+
291
+ # Rack::Headers arrived with Rack 3 — and version constants aren't loaded by
292
+ # rack/utils alone, so the class is the reliable tell.
293
+ def rack3? = defined?(Rack::Headers) ? true : false
294
+
295
+ def header_values(headers, name)
296
+ value = headers[name] || headers[name.split("-").map(&:capitalize).join("-")]
297
+ value.is_a?(String) ? value.split("\n") : value
298
+ end
299
+
300
+ # A same-origin path (with query), or nil. Absolute URLs are accepted only
301
+ # for the app's own host — everything here ends up in a Location header or
302
+ # a redirect target.
303
+ def same_origin_path(value, env)
304
+ path = value.to_s
305
+ return nil if path.empty?
306
+
307
+ if (match = path.match(%r{\Ahttps?://([^/]+)(/.*)?\z}i))
308
+ return nil unless match[1].casecmp?(env["HTTP_HOST"].to_s)
309
+
310
+ path = match[2].to_s
311
+ path = "/" if path.empty?
312
+ end
313
+ path if path.start_with?("/") && !path.start_with?("//")
314
+ end
315
+
316
+ # MARK: cookies
317
+
318
+ def request_jar(env)
319
+ self.class.request_jar(env)
320
+ end
321
+
322
+ class << self
323
+ # The request's cookies, values left exactly as they arrived — re-escaping
324
+ # a decoded value would corrupt any cookie the app didn't escape the same
325
+ # way. First occurrence wins on a duplicate name, matching Rack's parser
326
+ # (browsers send the most specific cookie first), so the jar sealed here
327
+ # is the jar the app actually read.
328
+ def request_jar(env)
329
+ env["HTTP_COOKIE"].to_s.split(/;\s*/).each_with_object({}) do |pair, jar|
330
+ name, value = pair.split("=", 2)
331
+ next if name.nil? || value.nil?
332
+
333
+ name = name.strip
334
+ jar[name] = value unless jar.key?(name)
335
+ end
336
+ end
337
+
338
+ # The start marker riding a request, decrypted, or nil. Class-level
339
+ # because the OmniAuth request validator (everywhere/omniauth.rb) asks
340
+ # the same question of the same cookie.
341
+ def marker(env)
342
+ raw = request_jar(env)[MARKER_COOKIE] or return nil
343
+
344
+ AuthToken.decrypt(Rack::Utils.unescape(raw), purpose: MARKER_PURPOSE)
345
+ end
346
+ end
347
+
348
+ # Fold a response's Set-Cookie headers into the jar, honoring deletions —
349
+ # a sign-out mid-flow must not hand the app a session it just discarded.
350
+ # Returns when each surviving cookie expires ({name => unix time}, only
351
+ # the ones that said): the response is the flow's one chance to learn a
352
+ # lifetime, and what keeps a remember-me cookie from crossing into the
353
+ # web view session-scoped.
354
+ def apply_set_cookies(jar, headers)
355
+ expiries = {}
356
+ Array(header_values(headers, "set-cookie")).each do |line|
357
+ pair, *attributes = line.to_s.split(/;\s*/)
358
+ name, value = pair.to_s.split("=", 2)
359
+ next if name.nil? || value.nil?
360
+
361
+ name = name.strip
362
+ expiry = expiry_of(attributes)
363
+ if value.empty? || (expiry && expiry <= Time.now.to_i)
364
+ jar.delete(name)
365
+ expiries.delete(name)
366
+ else
367
+ jar[name] = value
368
+ expiries[name] = expiry if expiry
369
+ end
370
+ end
371
+ expiries
372
+ end
373
+
374
+ # When the cookie line says it expires (unix time), nil for session scope.
375
+ # Max-Age wins over Expires, per RFC 6265.
376
+ def expiry_of(attributes)
377
+ expires = nil
378
+ attributes.each do |attribute|
379
+ key, value = attribute.split("=", 2)
380
+ case key.to_s.downcase
381
+ when "max-age" then return Time.now.to_i + value.to_i
382
+ when "expires" then expires = (Time.httpdate(value.to_s).to_i rescue nil)
383
+ end
384
+ end
385
+ expires
386
+ end
387
+
388
+ def marker_cookie(value, env, max_age:)
389
+ cookie(MARKER_COOKIE, value, env, max_age: max_age)
390
+ end
391
+
392
+ # Rebuilt rather than replayed attribute-for-attribute: a request's Cookie
393
+ # header carries names and values only, so the flags are ours to choose.
394
+ # HttpOnly across the board is the right default for an auth jar — a cookie
395
+ # the app's own JavaScript reads is not one it should be signing in with.
396
+ def cookie(name, value, env, max_age: nil)
397
+ parts = ["#{name}=#{value}", "path=/", "HttpOnly", "SameSite=Lax"]
398
+ parts << "secure" if env["rack.url_scheme"] == "https" || env["HTTPS"] == "on"
399
+ parts << "max-age=#{max_age}" if max_age
400
+ parts << "expires=#{Time.at(0).httpdate}" if max_age&.zero?
401
+ parts.join("; ")
402
+ end
403
+ end
404
+ 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