ruby_everywhere 0.2.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/README.md +48 -0
- data/bridge/everywhere/bridge.js +388 -3
- data/bridge/everywhere/native.css +203 -0
- data/bridge/package.json +6 -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 +72 -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 +513 -14
- data/lib/everywhere/emulator.rb +405 -0
- data/lib/everywhere/engine.rb +21 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/log_filter.rb +74 -0
- data/lib/everywhere/mobile_config_endpoint.rb +13 -0
- data/lib/everywhere/mobile_configs_controller.rb +18 -0
- data/lib/everywhere/native_helper.rb +198 -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 +7 -1
- data/support/mobile/ios/App/AuthFlow.swift +101 -0
- data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +105 -0
- data/support/mobile/ios/App/SceneDelegate.swift +144 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +8 -0
- data/support/mobile/ios/README.md +10 -0
- metadata +69 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Everywhere
|
|
7
|
+
# Compiles an app's native/ios/assets/ tree into Assets.xcassets imagesets so
|
|
8
|
+
# native code can reach them by name — Image("logo") in SwiftUI,
|
|
9
|
+
# UIImage(named: "logo") in UIKit. The catalog already ships in the frozen
|
|
10
|
+
# template and is compiled by actool, so writing imagesets into it keeps the
|
|
11
|
+
# stamp-only contract (the pbxproj is never touched).
|
|
12
|
+
#
|
|
13
|
+
# Filenames are the whole API: <name>[@2x|@3x][~dark].<ext>. Sub-directories
|
|
14
|
+
# organise the catalog but do NOT namespace it — asset names stay global, so
|
|
15
|
+
# assets/branding/logo.png is still Image("logo").
|
|
16
|
+
module AssetCatalog
|
|
17
|
+
RASTER = %w[.png .jpg .jpeg].freeze
|
|
18
|
+
VECTOR = %w[.pdf .svg].freeze
|
|
19
|
+
EXTENSIONS = (RASTER + VECTOR).freeze
|
|
20
|
+
|
|
21
|
+
# Written by the iOS builder itself (icon, launch background, tint) — an
|
|
22
|
+
# app asset of the same name would silently lose to, or clobber, those.
|
|
23
|
+
RESERVED = %w[AppIcon AccentColor LaunchBackground].freeze
|
|
24
|
+
|
|
25
|
+
VALID_NAME = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/
|
|
26
|
+
INFO = { "author" => "xcode", "version" => 1 }.freeze
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Copy every image under `source` into `catalog` as an imageset. Returns
|
|
30
|
+
# [names, errors]; nothing is written when errors is non-empty, so a
|
|
31
|
+
# broken assets/ tree can't half-stamp a catalog.
|
|
32
|
+
def write!(source, catalog)
|
|
33
|
+
sets, errors = plan(source)
|
|
34
|
+
return [[], errors] unless errors.empty?
|
|
35
|
+
|
|
36
|
+
sets.each do |set|
|
|
37
|
+
group = set[:dir] == "." ? catalog : File.join(catalog, set[:dir])
|
|
38
|
+
dir = File.join(group, "#{set[:name]}.imageset")
|
|
39
|
+
FileUtils.mkdir_p(dir)
|
|
40
|
+
set[:variants].each { |v| FileUtils.cp(v[:path], File.join(dir, File.basename(v[:path]))) }
|
|
41
|
+
File.write(File.join(dir, "Contents.json"), "#{JSON.pretty_generate(contents(set))}\n")
|
|
42
|
+
write_group_folders(catalog, set[:dir])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
[sets.map { |set| set[:name] }.sort, []]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Group the tree into one imageset per name, validating as we go. Errors
|
|
51
|
+
# are collected (not raised) so one `every build` reports every problem.
|
|
52
|
+
def plan(source)
|
|
53
|
+
errors = []
|
|
54
|
+
files = Dir.glob(File.join(source, "**", "*"))
|
|
55
|
+
.select { |path| File.file?(path) && !File.basename(path).start_with?(".") }
|
|
56
|
+
.sort
|
|
57
|
+
images, other = files.partition { |path| EXTENSIONS.include?(File.extname(path).downcase) }
|
|
58
|
+
|
|
59
|
+
other.each do |path|
|
|
60
|
+
errors << "#{relative(path, source)}: unsupported image type — use #{EXTENSIONS.join(", ")}"
|
|
61
|
+
end
|
|
62
|
+
variants = images.filter_map { |path| variant(path, source, errors) }
|
|
63
|
+
|
|
64
|
+
sets = variants.group_by { |v| v[:name] }.map do |name, group|
|
|
65
|
+
dirs = group.map { |v| v[:dir] }.uniq
|
|
66
|
+
if dirs.length > 1
|
|
67
|
+
errors << "#{name}: same asset name in #{dirs.map { |d| d == "." ? "assets/" : "assets/#{d}/" }.join(" and ")} " \
|
|
68
|
+
"— names are global in the catalog, so rename one"
|
|
69
|
+
end
|
|
70
|
+
errors.concat(slot_errors(name, group))
|
|
71
|
+
{ name: name, dir: dirs.first, variants: group }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
[sets, errors]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def variant(path, source, errors)
|
|
78
|
+
relative = relative(path, source)
|
|
79
|
+
extension = File.extname(path).downcase
|
|
80
|
+
name, scale, dark = parse(File.basename(path, File.extname(path)))
|
|
81
|
+
|
|
82
|
+
unless name.match?(VALID_NAME)
|
|
83
|
+
errors << "#{relative}: #{name.inspect} isn't a usable asset name — " \
|
|
84
|
+
"use letters, digits, _, - and ."
|
|
85
|
+
return nil
|
|
86
|
+
end
|
|
87
|
+
if RESERVED.include?(name)
|
|
88
|
+
errors << "#{relative}: #{name} is reserved (stamped from config/everywhere.yml) — rename it"
|
|
89
|
+
return nil
|
|
90
|
+
end
|
|
91
|
+
if scale && VECTOR.include?(extension)
|
|
92
|
+
errors << "#{relative}: vector assets are scale-free — drop the @#{scale} suffix"
|
|
93
|
+
return nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
{ name: name, dir: File.dirname(relative), scale: scale, dark: dark,
|
|
97
|
+
vector: VECTOR.include?(extension), path: path, relative: relative }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Trailing @2x/@3x and ~dark markers, in either order, off the stem.
|
|
101
|
+
def parse(base)
|
|
102
|
+
name = base.dup
|
|
103
|
+
scale = nil
|
|
104
|
+
dark = false
|
|
105
|
+
loop do
|
|
106
|
+
if name.sub!(/~dark\z/, "")
|
|
107
|
+
dark = true
|
|
108
|
+
elsif name.sub!(/@([23])x\z/, "")
|
|
109
|
+
scale = "#{Regexp.last_match(1)}x"
|
|
110
|
+
else
|
|
111
|
+
break
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
[name, scale, dark]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def slot_errors(name, group)
|
|
118
|
+
errors = []
|
|
119
|
+
if group.map { |v| v[:vector] }.uniq.length > 1
|
|
120
|
+
errors << "#{name}: mixes vector and raster files — pick one per asset"
|
|
121
|
+
end
|
|
122
|
+
group.group_by { |v| [v[:scale], v[:dark]] }.each_value do |clash|
|
|
123
|
+
next if clash.length < 2
|
|
124
|
+
|
|
125
|
+
errors << "#{name}: #{clash.map { |v| v[:relative] }.join(" and ")} both claim the same slot"
|
|
126
|
+
end
|
|
127
|
+
errors
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Two shapes: a 1x/2x/3x set when any file carries a scale suffix (empty
|
|
131
|
+
# slots are declared, which is what Xcode itself writes), otherwise a
|
|
132
|
+
# single-scale set — one file used at every scale. Bare filenames get
|
|
133
|
+
# single-scale so a 3x-resolution export isn't silently treated as 1x.
|
|
134
|
+
def contents(set)
|
|
135
|
+
images =
|
|
136
|
+
if set[:variants].any? { |v| v[:scale] }
|
|
137
|
+
%w[1x 2x 3x].flat_map { |scale| slots(set, scale) }
|
|
138
|
+
else
|
|
139
|
+
slots(set, nil)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
{ "images" => images, "info" => INFO }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def slots(set, scale)
|
|
146
|
+
[false, true].filter_map do |dark|
|
|
147
|
+
# A light slot always exists (Xcode declares empty ones); a dark slot
|
|
148
|
+
# only when the app actually shipped a ~dark file for it.
|
|
149
|
+
file = set[:variants].find { |v| v[:dark] == dark && (scale.nil? || (v[:scale] || "1x") == scale) }
|
|
150
|
+
next if dark && file.nil?
|
|
151
|
+
|
|
152
|
+
entry = { "idiom" => "universal" }
|
|
153
|
+
entry["appearances"] = [{ "appearance" => "luminosity", "value" => "dark" }] if dark
|
|
154
|
+
entry["filename"] = File.basename(file[:path]) if file
|
|
155
|
+
entry["scale"] = scale if scale
|
|
156
|
+
entry["properties"] = { "preserves-vector-representation" => true } if file && file[:vector]
|
|
157
|
+
entry
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Plain catalog folders (no provides-namespace) — organisation only, so
|
|
162
|
+
# assets/branding/logo.png stays Image("logo").
|
|
163
|
+
def write_group_folders(catalog, dir)
|
|
164
|
+
return if dir == "."
|
|
165
|
+
|
|
166
|
+
path_prefixes(dir).each do |prefix|
|
|
167
|
+
path = File.join(catalog, prefix)
|
|
168
|
+
FileUtils.mkdir_p(path)
|
|
169
|
+
contents = File.join(path, "Contents.json")
|
|
170
|
+
File.write(contents, "#{JSON.pretty_generate("info" => INFO)}\n") unless File.exist?(contents)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def path_prefixes(dir)
|
|
175
|
+
dir.split(File::SEPARATOR).each_with_object([]) do |part, acc|
|
|
176
|
+
acc << (acc.empty? ? part : File.join(acc.last, part))
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def relative(path, source)
|
|
181
|
+
path.delete_prefix("#{source}/")
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -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
|