masks 0.11.0 → 0.12.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/CHANGELOG.md +33 -0
- data/README.md +28 -461
- data/app/controllers/masks/rails/handshakes_controller.rb +2 -10
- data/app/controllers/masks/rails/logouts_controller.rb +11 -8
- data/app/controllers/masks/rails/sessions_controller.rb +46 -5
- data/config/routes.rb +1 -0
- data/lib/masks/client/embedded.rb +1 -1
- data/lib/masks/client/handshake.rb +12 -4
- data/lib/masks/client/http.rb +2 -13
- data/lib/masks/client/logout.rb +5 -1
- data/lib/masks/client/resource.rb +1 -1
- data/lib/masks/client/verifier.rb +10 -2
- data/lib/masks/client.rb +0 -4
- data/lib/masks/rails/authentication.rb +98 -33
- data/lib/masks/rails/cache.rb +173 -0
- data/lib/masks/rails/configuration.rb +24 -14
- data/lib/masks/rails/renewal.rb +54 -0
- data/lib/masks/rails/session_cookies.rb +145 -0
- data/lib/masks/rails.rb +5 -4
- data/lib/masks/version.rb +1 -1
- data/lib/masks.rb +0 -1
- metadata +4 -1
|
@@ -3,6 +3,9 @@ module Masks
|
|
|
3
3
|
class SessionsController < BaseController
|
|
4
4
|
RESTARTED = "masks_restarted_at".freeze
|
|
5
5
|
RESTART_WITHIN = 60
|
|
6
|
+
SAME_ORIGIN = %w[same-origin none].freeze
|
|
7
|
+
|
|
8
|
+
before_action :refuse_cross_site, only: :destroy
|
|
6
9
|
|
|
7
10
|
def show
|
|
8
11
|
if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
|
|
@@ -10,7 +13,7 @@ module Masks
|
|
|
10
13
|
|
|
11
14
|
render json: masked.to_h
|
|
12
15
|
else
|
|
13
|
-
masks_forget
|
|
16
|
+
masks_forget unless masks_configured?
|
|
14
17
|
masks_refuse_json
|
|
15
18
|
end
|
|
16
19
|
end
|
|
@@ -40,7 +43,8 @@ module Masks
|
|
|
40
43
|
def callback
|
|
41
44
|
pending = masks_requests.claim(params[:state])
|
|
42
45
|
|
|
43
|
-
return refuse(params[:error], params[:error_description], pending) if params[:error].present?
|
|
46
|
+
return refuse(params[:error], params[:error_description], pending) if params[:error].present? && pending
|
|
47
|
+
return unstarted if params[:error].present?
|
|
44
48
|
return stale if pending.nil?
|
|
45
49
|
|
|
46
50
|
tokens = masks_session.complete(
|
|
@@ -83,15 +87,42 @@ module Masks
|
|
|
83
87
|
if masks_wants_json?
|
|
84
88
|
render json: { "signed_in" => false, "logout_url" => upstream }.compact
|
|
85
89
|
else
|
|
86
|
-
redirect_to upstream ||
|
|
90
|
+
redirect_to upstream || masks_after_sign_out_path, allow_other_host: upstream.present?
|
|
87
91
|
end
|
|
88
92
|
end
|
|
89
93
|
|
|
94
|
+
def signed_out
|
|
95
|
+
return stale_sign_out if masks_farewells.claim(params[:state].to_s).nil?
|
|
96
|
+
|
|
97
|
+
redirect_to masks_after_sign_out_path
|
|
98
|
+
end
|
|
99
|
+
|
|
90
100
|
private
|
|
91
101
|
|
|
102
|
+
def refuse_cross_site
|
|
103
|
+
return if same_origin?
|
|
104
|
+
|
|
105
|
+
answer("invalid_request", "that request came from another site", :forbidden, failed: "sign-out")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def same_origin?
|
|
109
|
+
site = request.headers["Sec-Fetch-Site"].to_s
|
|
110
|
+
|
|
111
|
+
return false unless site.empty? || SAME_ORIGIN.include?(site)
|
|
112
|
+
|
|
113
|
+
sent = request.headers["Origin"].to_s
|
|
114
|
+
|
|
115
|
+
sent.empty? || [ request.base_url, own_origin ].include?(sent)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def own_origin
|
|
119
|
+
URI.join(masks_config.redirect_uri_for(request), "/").to_s.chomp("/")
|
|
120
|
+
end
|
|
121
|
+
|
|
92
122
|
def renew_session
|
|
93
123
|
carried = session.to_hash.slice(REQUESTS, HANDSHAKES, Masks::Rails::Authentication::STEPPED_UP)
|
|
94
124
|
|
|
125
|
+
masks_forget
|
|
95
126
|
reset_session
|
|
96
127
|
carried.each { |key, value| session[key] = value }
|
|
97
128
|
end
|
|
@@ -130,6 +161,16 @@ module Masks
|
|
|
130
161
|
)
|
|
131
162
|
end
|
|
132
163
|
|
|
164
|
+
def unstarted
|
|
165
|
+
answer("invalid_state", "masks refused a sign-in that this browser did not start, or one that expired",
|
|
166
|
+
:bad_request)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def stale_sign_out
|
|
170
|
+
answer("invalid_state", "that sign-out is not one this browser started, or it expired", :bad_request,
|
|
171
|
+
failed: "sign-out")
|
|
172
|
+
end
|
|
173
|
+
|
|
133
174
|
def restart
|
|
134
175
|
session[RESTARTED] = Time.now.to_i
|
|
135
176
|
redirect_to start_path
|
|
@@ -145,11 +186,11 @@ module Masks
|
|
|
145
186
|
answer(code, description, :bad_request)
|
|
146
187
|
end
|
|
147
188
|
|
|
148
|
-
def answer(code, description, status)
|
|
189
|
+
def answer(code, description, status, failed: "sign-in")
|
|
149
190
|
if masks_wants_json?
|
|
150
191
|
render json: { "error" => code, "error_description" => description }, status: status
|
|
151
192
|
else
|
|
152
|
-
render plain: "
|
|
193
|
+
render plain: "#{failed} failed — #{code}: #{description}", status: status
|
|
153
194
|
end
|
|
154
195
|
end
|
|
155
196
|
end
|
data/config/routes.rb
CHANGED
|
@@ -4,6 +4,7 @@ Masks::Rails::Engine.routes.draw do
|
|
|
4
4
|
get "/avatar", to: "avatars#show", as: :avatar
|
|
5
5
|
get "/callback", to: "sessions#callback", as: :callback
|
|
6
6
|
match "/logout", to: "sessions#destroy", via: %i[get post delete], as: :logout
|
|
7
|
+
get "/logout/return", to: "sessions#signed_out", as: :logout_return
|
|
7
8
|
post "/logout/backchannel", to: "logouts#create", as: :backchannel_logout
|
|
8
9
|
|
|
9
10
|
get "/handshake", to: "handshakes#show", as: :handshake
|
|
@@ -74,7 +74,7 @@ module Masks
|
|
|
74
74
|
"SERVER_PORT" => uri.port.to_s,
|
|
75
75
|
"SERVER_PROTOCOL" => "HTTP/1.1",
|
|
76
76
|
"REMOTE_ADDR" => LOOPBACK,
|
|
77
|
-
"HTTP_HOST" =>
|
|
77
|
+
"HTTP_HOST" => Nonces.origin(uri).delete_prefix("#{uri.scheme}://"),
|
|
78
78
|
"HTTPS" => uri.scheme == "https" ? "on" : "off",
|
|
79
79
|
"rack.url_scheme" => uri.scheme,
|
|
80
80
|
"rack.input" => StringIO.new(input),
|
|
@@ -6,17 +6,22 @@ module Masks
|
|
|
6
6
|
AUTH_METHOD = "client_secret_basic".freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :issuer, :name, :resource, :redirect_uris, :scope, :return_to,
|
|
9
|
-
:
|
|
9
|
+
:post_logout_redirect_uri
|
|
10
10
|
|
|
11
11
|
def initialize(issuer, name:, resource:, redirect_uris:, return_to:,
|
|
12
|
-
scope: Session::DEFAULT_SCOPE, backchannel_logout_uri: nil)
|
|
12
|
+
scope: Session::DEFAULT_SCOPE, backchannel_logout_uri: nil, post_logout_redirect_uri: nil)
|
|
13
13
|
@issuer = Issuer.resolve(issuer)
|
|
14
14
|
@name = name.to_s
|
|
15
15
|
@resource = resource.to_s
|
|
16
16
|
@redirect_uris = Array(redirect_uris).map(&:to_s)
|
|
17
17
|
@scope = Array(scope).flat_map { |value| value.to_s.split(/\s+/) }.reject(&:empty?)
|
|
18
18
|
@return_to = return_to.to_s
|
|
19
|
-
@backchannel_logout_uri = backchannel_logout_uri
|
|
19
|
+
@backchannel_logout_uri = backchannel_logout_uri.to_s if present?(backchannel_logout_uri)
|
|
20
|
+
@post_logout_redirect_uri = post_logout_redirect_uri.to_s if present?(post_logout_redirect_uri)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def backchannel_logout_uri
|
|
24
|
+
@backchannel_logout_uri if @backchannel_logout_uri && issuer.backchannel_logout?
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
def endpoint
|
|
@@ -36,7 +41,10 @@ module Masks
|
|
|
36
41
|
[ "state", state ]
|
|
37
42
|
]
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
backchannel = backchannel_logout_uri
|
|
45
|
+
|
|
46
|
+
query << [ "backchannel_logout_uri", backchannel ] if backchannel
|
|
47
|
+
query << [ "post_logout_redirect_uri", post_logout_redirect_uri ] if post_logout_redirect_uri
|
|
40
48
|
redirect_uris.each { |uri| query << [ "redirect_uris", uri ] }
|
|
41
49
|
|
|
42
50
|
"#{endpoint}?#{URI.encode_www_form(query)}"
|
data/lib/masks/client/http.rb
CHANGED
|
@@ -4,6 +4,7 @@ module Masks
|
|
|
4
4
|
OPEN_TIMEOUT = 5
|
|
5
5
|
READ_TIMEOUT = 10
|
|
6
6
|
READ_LIMIT = 1 << 20
|
|
7
|
+
FETCH_LIMIT = 8 << 20
|
|
7
8
|
|
|
8
9
|
module_function
|
|
9
10
|
|
|
@@ -43,19 +44,7 @@ module Masks
|
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
def fetch(url, headers = {})
|
|
46
|
-
|
|
47
|
-
embedded = Masks::Client.embedded_for(uri)
|
|
48
|
-
|
|
49
|
-
return embedded.deliver(Net::HTTP::Get.new(uri, headers)) if embedded
|
|
50
|
-
|
|
51
|
-
Net::HTTP.start(
|
|
52
|
-
uri.hostname, uri.port,
|
|
53
|
-
use_ssl: uri.scheme == "https",
|
|
54
|
-
open_timeout: OPEN_TIMEOUT,
|
|
55
|
-
read_timeout: READ_TIMEOUT
|
|
56
|
-
) { |http| http.request(Net::HTTP::Get.new(uri, headers)) }
|
|
57
|
-
rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
|
|
58
|
-
raise Unreachable, "#{uri.host} is unreachable (#{e.class})"
|
|
47
|
+
deliver(Net::HTTP::Get.new(checked(URI.parse(url.to_s)), headers), limit: FETCH_LIMIT)
|
|
59
48
|
end
|
|
60
49
|
|
|
61
50
|
def json(verb, url, body, headers)
|
data/lib/masks/client/logout.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Masks
|
|
|
9
9
|
def verify(token, issuer:, audience:, algorithms: ALGORITHMS)
|
|
10
10
|
held = Verifier
|
|
11
11
|
.new(issuer, audience: audience, algorithms: algorithms)
|
|
12
|
-
.verify(token, required: %w[iss aud iat jti events], typ: Verifier::LOGOUT_TOKEN)
|
|
12
|
+
.verify(token, required: %w[iss aud iat exp jti events], typ: Verifier::LOGOUT_TOKEN)
|
|
13
13
|
|
|
14
14
|
new(held).validate!
|
|
15
15
|
end
|
|
@@ -37,6 +37,10 @@ module Masks
|
|
|
37
37
|
Time.at(claims["iat"].to_i).utc
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def expires_at
|
|
41
|
+
Time.at(claims["exp"].to_i).utc
|
|
42
|
+
end
|
|
43
|
+
|
|
40
44
|
def validate!
|
|
41
45
|
refuse!("logout token carries a nonce, so it is an id token") if claims.key?("nonce")
|
|
42
46
|
refuse!("logout token names neither a subject nor a session") if subject.nil? && sid.nil?
|
|
@@ -57,7 +57,7 @@ module Masks
|
|
|
57
57
|
|
|
58
58
|
parameters = [
|
|
59
59
|
[ "error", error.code ],
|
|
60
|
-
[ "error_description", error.description ],
|
|
60
|
+
[ "error_description", (error.description if error.code) ],
|
|
61
61
|
[ "scope", error.scope || (scopes.join(" ") if scopes.any?) ],
|
|
62
62
|
[ "resource_metadata", metadata_url ]
|
|
63
63
|
]
|
|
@@ -28,8 +28,16 @@ module Masks
|
|
|
28
28
|
typed!(header["typ"], Array(typ))
|
|
29
29
|
|
|
30
30
|
claims
|
|
31
|
-
rescue JWT::
|
|
32
|
-
raise InvalidToken,
|
|
31
|
+
rescue JWT::ExpiredSignature
|
|
32
|
+
raise InvalidToken, "that token has expired"
|
|
33
|
+
rescue JWT::InvalidIssuerError
|
|
34
|
+
raise InvalidToken, "that token was issued by another server"
|
|
35
|
+
rescue JWT::InvalidAudError
|
|
36
|
+
raise InvalidToken, "that token was issued for another app"
|
|
37
|
+
rescue JWT::MissingRequiredClaim
|
|
38
|
+
raise InvalidToken, "that token is missing a claim it needs"
|
|
39
|
+
rescue JWT::DecodeError
|
|
40
|
+
raise InvalidToken, "that token was not signed by #{issuer.url}"
|
|
33
41
|
end
|
|
34
42
|
|
|
35
43
|
private
|
data/lib/masks/client.rb
CHANGED
|
@@ -46,9 +46,6 @@ module Masks
|
|
|
46
46
|
# [verifier] an API checking a bearer it was handed
|
|
47
47
|
# [resource] an API publishing what it is and which scopes it takes
|
|
48
48
|
# [handshake] an app registering itself, once, without a copied secret
|
|
49
|
-
#
|
|
50
|
-
# Everything reachable from here talks HTTP to a masks issuer and holds no
|
|
51
|
-
# state of its own beyond the discovery cache in ::registry.
|
|
52
49
|
module Client
|
|
53
50
|
class << self
|
|
54
51
|
attr_writer :plain_http
|
|
@@ -78,7 +75,6 @@ module Masks
|
|
|
78
75
|
# cheap after the first.
|
|
79
76
|
#
|
|
80
77
|
# issuer = Masks::Client.issuer("https://auth.example")
|
|
81
|
-
# issuer.authorization_url(client_id: id, redirect_uri: uri)
|
|
82
78
|
#
|
|
83
79
|
# @return [Masks::Client::Issuer]
|
|
84
80
|
def issuer(url, **options)
|
|
@@ -9,6 +9,10 @@ module Masks
|
|
|
9
9
|
if respond_to?(:helper_method)
|
|
10
10
|
helper_method :masked, :masks_login_url
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
if respond_to?(:rescue_from) && method_defined?(:render)
|
|
14
|
+
rescue_from Masks::Rails::Cache::Unavailable, with: :masks_cache_unavailable
|
|
15
|
+
end
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
REQUESTS = "masks_requests".freeze
|
|
@@ -40,24 +44,42 @@ module Masks
|
|
|
40
44
|
def masks_tokens
|
|
41
45
|
return @masks_tokens if defined?(@masks_tokens)
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
held = masks_held
|
|
48
|
+
|
|
49
|
+
if held.present? && masks_departed?(held)
|
|
50
|
+
masks_forget
|
|
51
|
+
return nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@masks_tokens = Masks::Client::Tokens.from_h(held)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def masks_cache
|
|
58
|
+
@masks_cache ||= Masks::Rails::Cache.new(masks_config.cache)
|
|
44
59
|
end
|
|
45
60
|
|
|
46
61
|
def masks_store(tokens, identity: nil)
|
|
47
62
|
held = tokens.to_h.except("id_token")
|
|
63
|
+
held.delete("token_type") if held["token_type"] == "Bearer"
|
|
48
64
|
held["identity"] = identity&.slice(*IDENTITY) || masks_held["identity"]
|
|
65
|
+
held["signed_in_at"] = masks_held["signed_in_at"] || Masks::Rails::Cache.now
|
|
49
66
|
|
|
50
|
-
|
|
67
|
+
masks_session_cookies.write(held.compact)
|
|
68
|
+
@masks_held = held.compact
|
|
51
69
|
@masks_tokens = tokens
|
|
52
70
|
@masked = nil
|
|
53
71
|
end
|
|
54
72
|
|
|
55
73
|
def masks_held
|
|
56
|
-
|
|
74
|
+
@masks_held ||= masks_session_cookies.read || {}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def masks_session_cookies
|
|
78
|
+
Masks::Rails::SessionCookies.new(request, masks_config.session_key)
|
|
57
79
|
end
|
|
58
80
|
|
|
59
81
|
IDENTITY = [
|
|
60
|
-
"sub", "name", "preferred_username", "email", "email_verified", "tenant",
|
|
82
|
+
"sub", "sid", "name", "preferred_username", "email", "email_verified", "tenant",
|
|
61
83
|
"picture", Masks::Client::Claims::AVATARS, Masks::Client::Claims::ORGANIZATION,
|
|
62
84
|
Masks::Client::Claims::ORGANIZATIONS
|
|
63
85
|
].freeze
|
|
@@ -69,7 +91,8 @@ module Masks
|
|
|
69
91
|
end
|
|
70
92
|
|
|
71
93
|
def masks_forget
|
|
72
|
-
|
|
94
|
+
masks_session_cookies.clear
|
|
95
|
+
@masks_held = {}
|
|
73
96
|
@masks_tokens = nil
|
|
74
97
|
@masked = nil
|
|
75
98
|
end
|
|
@@ -129,22 +152,34 @@ module Masks
|
|
|
129
152
|
end
|
|
130
153
|
|
|
131
154
|
def masks_refresh!
|
|
132
|
-
return false unless masks_configured?
|
|
133
|
-
return false if masks_tokens&.refresh_token.nil?
|
|
155
|
+
return false unless masks_configured? && masks_tokens
|
|
134
156
|
|
|
135
|
-
|
|
136
|
-
masks_tokens.refresh_token,
|
|
137
|
-
resource: masks_config.resource_for(request)
|
|
138
|
-
)
|
|
157
|
+
spent = masks_tokens.refresh_token
|
|
139
158
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
159
|
+
if spent.nil?
|
|
160
|
+
masks_forget
|
|
161
|
+
return false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
renewal = begin
|
|
165
|
+
masks_cache.renew(masks_config.issuer_for(request).to_s, spent) { masks_renewal(spent) }
|
|
166
|
+
rescue Masks::Rails::Cache::Unavailable
|
|
167
|
+
Masks::Rails::Renewal.unreachable
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
case renewal.outcome
|
|
171
|
+
when :renewed
|
|
172
|
+
masks_store(renewal.tokens, identity: renewal.identity)
|
|
173
|
+
true
|
|
174
|
+
when :unregistered
|
|
175
|
+
masks_disconnect!
|
|
176
|
+
false
|
|
177
|
+
when :refused
|
|
178
|
+
masks_forget
|
|
179
|
+
false
|
|
180
|
+
else
|
|
181
|
+
false
|
|
182
|
+
end
|
|
148
183
|
end
|
|
149
184
|
|
|
150
185
|
def masks_refreshed_identity(tokens)
|
|
@@ -154,18 +189,9 @@ module Masks
|
|
|
154
189
|
|
|
155
190
|
profile = masks_session.profile(tokens)
|
|
156
191
|
|
|
157
|
-
profile.empty?
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def masks_logout_url(state:)
|
|
161
|
-
return nil unless masks_configured?
|
|
192
|
+
return nil if profile.empty? || profile["sub"].to_s != held["sub"].to_s
|
|
162
193
|
|
|
163
|
-
|
|
164
|
-
post_logout_redirect_uri: masks_config.return_to_for(request),
|
|
165
|
-
state: state
|
|
166
|
-
)
|
|
167
|
-
rescue Masks::Client::Error
|
|
168
|
-
nil
|
|
194
|
+
held.merge(profile.slice(*IDENTITY))
|
|
169
195
|
end
|
|
170
196
|
|
|
171
197
|
def masks_login_url(return_to: nil, organization: nil, privilege: nil)
|
|
@@ -194,14 +220,41 @@ module Masks
|
|
|
194
220
|
|
|
195
221
|
private
|
|
196
222
|
|
|
223
|
+
def masks_departed?(held)
|
|
224
|
+
identity = held["identity"]
|
|
225
|
+
|
|
226
|
+
return false unless identity.is_a?(Hash)
|
|
227
|
+
|
|
228
|
+
masks_cache.departed?(masks_config.issuer_for(request).to_s, identity, since: held["signed_in_at"].to_i)
|
|
229
|
+
rescue Masks::Rails::Configuration::Unconfigured
|
|
230
|
+
false
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def masks_renewal(spent)
|
|
234
|
+
refreshed = masks_session.refresh(spent, resource: masks_config.resource_for(request))
|
|
235
|
+
refreshed = Masks::Client::Tokens.from_h(refreshed.to_h.merge("refresh_token" => spent)) if refreshed.refresh_token.nil?
|
|
236
|
+
|
|
237
|
+
Masks::Rails::Renewal.renewed(refreshed, masks_refreshed_identity(refreshed))
|
|
238
|
+
rescue Masks::Client::Unregistered
|
|
239
|
+
Masks::Rails::Renewal.unregistered
|
|
240
|
+
rescue Masks::Client::Rejected => e
|
|
241
|
+
e.status.to_i >= 500 || e.status.to_i == 429 ? Masks::Rails::Renewal.unreachable : Masks::Rails::Renewal.refused
|
|
242
|
+
rescue Masks::Client::Error
|
|
243
|
+
Masks::Rails::Renewal.unreachable
|
|
244
|
+
end
|
|
245
|
+
|
|
197
246
|
def masks_sign_out_upstream
|
|
198
247
|
return nil unless masks_configured?
|
|
199
248
|
|
|
200
249
|
farewell = masks_farewells.open
|
|
201
|
-
url = masks_logout_url(state: farewell.id)
|
|
202
250
|
|
|
203
|
-
|
|
204
|
-
|
|
251
|
+
masks_session.end_session_url(
|
|
252
|
+
post_logout_redirect_uri: masks_config.post_logout_redirect_uri_for(request),
|
|
253
|
+
state: farewell.id
|
|
254
|
+
)
|
|
255
|
+
rescue Masks::Client::Error
|
|
256
|
+
masks_farewells.clear!
|
|
257
|
+
nil
|
|
205
258
|
end
|
|
206
259
|
|
|
207
260
|
def masks_after_sign_out_path
|
|
@@ -268,6 +321,18 @@ module Masks
|
|
|
268
321
|
redirect_to Masks::Rails::Engine.routes.url_helpers.start_path
|
|
269
322
|
end
|
|
270
323
|
|
|
324
|
+
def masks_cache_unavailable(error)
|
|
325
|
+
response.headers["Cache-Control"] = "no-store"
|
|
326
|
+
response.headers["Retry-After"] = "5"
|
|
327
|
+
|
|
328
|
+
if masks_wants_json?
|
|
329
|
+
render json: { "error" => "temporarily_unavailable", "error_description" => error.message },
|
|
330
|
+
status: :service_unavailable
|
|
331
|
+
else
|
|
332
|
+
render plain: "Signing in is unavailable right now. Try again in a moment.", status: :service_unavailable
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
271
336
|
def masks_refuse_json
|
|
272
337
|
response.headers["Cache-Control"] = "no-store"
|
|
273
338
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Rails
|
|
3
|
+
class Cache
|
|
4
|
+
class Unavailable < Masks::Client::Error; end
|
|
5
|
+
|
|
6
|
+
CLAIM = 30
|
|
7
|
+
RESULT = 60
|
|
8
|
+
WAIT = 0.05
|
|
9
|
+
PATIENCE = 30
|
|
10
|
+
LOGOUT = 600
|
|
11
|
+
LOGOUT_LEEWAY = 120
|
|
12
|
+
DEPARTURE = 30 * 24 * 3600
|
|
13
|
+
CIPHER = "aes-256-gcm".freeze
|
|
14
|
+
NONCE = 12
|
|
15
|
+
TAG = 16
|
|
16
|
+
|
|
17
|
+
def self.key(*parts)
|
|
18
|
+
"masks:#{OpenSSL::Digest::SHA256.hexdigest(parts.map(&:to_s).join("\0"))}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.now
|
|
22
|
+
(Time.now.to_f * 1000).to_i
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :store
|
|
26
|
+
|
|
27
|
+
def initialize(store)
|
|
28
|
+
@store = store
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def first?(key, expires_in:)
|
|
32
|
+
add(self.class.key("proof", key), expires_in)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def first_logout?(issuer, logout)
|
|
36
|
+
add(self.class.key("logout", issuer, logout.jti), logout_memory(logout))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def depart!(issuer, logout)
|
|
40
|
+
kind, value = logout.sid.to_s.empty? ? [ "sub", logout.subject ] : [ "sid", logout.sid ]
|
|
41
|
+
|
|
42
|
+
kept = begin
|
|
43
|
+
write(self.class.key("departed", issuer, kind, value), self.class.now, expires_in: DEPARTURE)
|
|
44
|
+
rescue Unavailable
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return if kept
|
|
49
|
+
|
|
50
|
+
forgotten { delete(self.class.key("logout", issuer, logout.jti)) }
|
|
51
|
+
|
|
52
|
+
raise Unavailable, "the cache did not keep that logout"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def departed?(issuer, identity, since:)
|
|
56
|
+
[ [ "sid", identity["sid"] ], [ "sub", identity["sub"] ] ].any? do |kind, value|
|
|
57
|
+
next false if value.to_s.empty?
|
|
58
|
+
|
|
59
|
+
at = read(self.class.key("departed", issuer, kind, value))
|
|
60
|
+
|
|
61
|
+
at.is_a?(Integer) && at >= since
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def renew(issuer, refresh_token)
|
|
66
|
+
result = self.class.key("refreshed", issuer, refresh_token)
|
|
67
|
+
claim = self.class.key("refreshing", issuer, refresh_token)
|
|
68
|
+
found = opened(result, refresh_token)
|
|
69
|
+
|
|
70
|
+
return found if found
|
|
71
|
+
return awaited(result, claim, refresh_token) unless add(claim, CLAIM)
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
held = yield
|
|
75
|
+
|
|
76
|
+
forgotten { write(result, sealed(refresh_token, held), expires_in: RESULT) } unless held.outcome == :unreachable
|
|
77
|
+
|
|
78
|
+
held
|
|
79
|
+
ensure
|
|
80
|
+
forgotten { delete(claim) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def add(key, expires_in)
|
|
87
|
+
return true if write(key, 1, unless_exist: true, expires_in: expires_in)
|
|
88
|
+
return false unless read(key).nil?
|
|
89
|
+
|
|
90
|
+
raise Unavailable, "the cache neither kept that key nor holds it, so it is not answering"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def read(key)
|
|
94
|
+
reached { store.read(key) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def write(key, value, **options)
|
|
98
|
+
reached { store.write(key, value, **options) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def delete(key)
|
|
102
|
+
reached { store.delete(key) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def reached
|
|
106
|
+
yield
|
|
107
|
+
rescue Unavailable
|
|
108
|
+
raise
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
raise Unavailable, "the cache could not be reached (#{e.class})"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def forgotten
|
|
114
|
+
yield
|
|
115
|
+
rescue Unavailable
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def logout_memory(logout)
|
|
120
|
+
[ LOGOUT, logout.expires_at.to_i - Time.now.to_i + LOGOUT_LEEWAY ].max
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def awaited(result, claim, refresh_token)
|
|
124
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATIENCE
|
|
125
|
+
|
|
126
|
+
while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
127
|
+
sleep WAIT
|
|
128
|
+
|
|
129
|
+
found = opened(result, refresh_token)
|
|
130
|
+
|
|
131
|
+
return found if found
|
|
132
|
+
return opened(result, refresh_token) || Renewal.unreachable if read(claim).nil?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
Renewal.unreachable
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def cipher(mode, refresh_token)
|
|
139
|
+
held = OpenSSL::Cipher.new(CIPHER).public_send(mode)
|
|
140
|
+
held.key = OpenSSL::KDF.hkdf(refresh_token.to_s, salt: "masks", info: "masks/rails shared refresh",
|
|
141
|
+
length: 32, hash: "SHA256")
|
|
142
|
+
held
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def sealed(refresh_token, held)
|
|
146
|
+
plain = JSON.generate(held.to_h)
|
|
147
|
+
sealing = cipher(:encrypt, refresh_token)
|
|
148
|
+
nonce = SecureRandom.random_bytes(NONCE)
|
|
149
|
+
sealing.iv = nonce
|
|
150
|
+
sealing.auth_data = ""
|
|
151
|
+
body = sealing.update(plain) + sealing.final
|
|
152
|
+
|
|
153
|
+
Base64.strict_encode64(nonce + sealing.auth_tag(TAG) + body)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def opened(result, refresh_token)
|
|
157
|
+
held = read(result)
|
|
158
|
+
|
|
159
|
+
return nil if held.nil?
|
|
160
|
+
|
|
161
|
+
raw = Base64.strict_decode64(held.to_s)
|
|
162
|
+
opening = cipher(:decrypt, refresh_token)
|
|
163
|
+
opening.iv = raw.byteslice(0, NONCE)
|
|
164
|
+
opening.auth_tag = raw.byteslice(NONCE, TAG)
|
|
165
|
+
opening.auth_data = ""
|
|
166
|
+
|
|
167
|
+
Renewal.from_h(JSON.parse(opening.update(raw.byteslice((NONCE + TAG)..).to_s) + opening.final))
|
|
168
|
+
rescue ArgumentError, TypeError, OpenSSL::Cipher::CipherError, JSON::ParserError
|
|
169
|
+
Renewal.unreachable
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|