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.
- checksums.yaml +4 -4
- data/README.md +255 -0
- data/bridge/everywhere/bridge.js +60 -3
- data/lib/everywhere/android_resources.rb +217 -0
- data/lib/everywhere/android_sdk.rb +330 -0
- data/lib/everywhere/asset_catalog.rb +185 -0
- data/lib/everywhere/auth_handoff.rb +398 -0
- data/lib/everywhere/auth_token.rb +118 -0
- data/lib/everywhere/builders/android.rb +844 -0
- data/lib/everywhere/builders/ios.rb +40 -3
- data/lib/everywhere/commands/build.rb +51 -9
- data/lib/everywhere/commands/clean.rb +10 -5
- data/lib/everywhere/commands/dev.rb +46 -8
- data/lib/everywhere/commands/doctor.rb +89 -3
- data/lib/everywhere/commands/install.rb +1 -1
- data/lib/everywhere/commands/logs.rb +117 -8
- data/lib/everywhere/config.rb +409 -13
- data/lib/everywhere/emulator.rb +405 -0
- data/lib/everywhere/engine.rb +12 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/log_filter.rb +74 -0
- data/lib/everywhere/native_helper.rb +2 -5
- data/lib/everywhere/omniauth.rb +88 -0
- data/lib/everywhere/paths.rb +47 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/android/README.md +217 -0
- data/support/mobile/android/app/build.gradle.kts +156 -0
- data/support/mobile/android/app/everywhere.properties +8 -0
- data/support/mobile/android/app/native-packages.gradle.kts +16 -0
- data/support/mobile/android/app/proguard-rules.pro +21 -0
- data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
- data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
- data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
- data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
- data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
- data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
- data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
- data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
- data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
- data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
- data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
- data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
- data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
- data/support/mobile/android/build.gradle.kts +8 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
- data/support/mobile/android/gradle.properties +14 -0
- data/support/mobile/android/gradlew +251 -0
- data/support/mobile/android/gradlew.bat +94 -0
- data/support/mobile/android/settings.gradle.kts +28 -0
- data/support/mobile/ios/App/AppDelegate.swift +5 -0
- data/support/mobile/ios/App/AuthFlow.swift +101 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
- data/support/mobile/ios/App/SceneDelegate.swift +103 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
- data/support/mobile/ios/README.md +4 -1
- 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
|