masks 0.10.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.
@@ -7,12 +7,17 @@ module Masks
7
7
 
8
8
  included do
9
9
  if respond_to?(:helper_method)
10
- helper_method :masked, :masks_login_url, :masks_logout_url
10
+ helper_method :masked, :masks_login_url
11
+ end
12
+
13
+ if respond_to?(:rescue_from) && method_defined?(:render)
14
+ rescue_from Masks::Rails::Cache::Unavailable, with: :masks_cache_unavailable
11
15
  end
12
16
  end
13
17
 
14
18
  REQUESTS = "masks_requests".freeze
15
19
  HANDSHAKES = "masks_handshakes".freeze
20
+ FAREWELLS = "masks_farewells".freeze
16
21
  STEPPED_UP = "masks_stepped_up".freeze
17
22
  STEP_UP_WITHIN = 300
18
23
 
@@ -28,6 +33,10 @@ module Masks
28
33
  @masks_handshakes ||= masks_tracker(HANDSHAKES)
29
34
  end
30
35
 
36
+ def masks_farewells
37
+ masks_tracker(FAREWELLS)
38
+ end
39
+
31
40
  def masks_tracker(key)
32
41
  Masks::Client::Tracker.new(Masks::Client::Stores::Session.new(session, key))
33
42
  end
@@ -35,24 +44,42 @@ module Masks
35
44
  def masks_tokens
36
45
  return @masks_tokens if defined?(@masks_tokens)
37
46
 
38
- @masks_tokens = Masks::Client::Tokens.from_h(session[masks_config.session_key])
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)
39
59
  end
40
60
 
41
61
  def masks_store(tokens, identity: nil)
42
62
  held = tokens.to_h.except("id_token")
63
+ held.delete("token_type") if held["token_type"] == "Bearer"
43
64
  held["identity"] = identity&.slice(*IDENTITY) || masks_held["identity"]
65
+ held["signed_in_at"] = masks_held["signed_in_at"] || Masks::Rails::Cache.now
44
66
 
45
- session[masks_config.session_key] = held.compact
67
+ masks_session_cookies.write(held.compact)
68
+ @masks_held = held.compact
46
69
  @masks_tokens = tokens
47
70
  @masked = nil
48
71
  end
49
72
 
50
73
  def masks_held
51
- session[masks_config.session_key] || {}
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)
52
79
  end
53
80
 
54
81
  IDENTITY = [
55
- "sub", "name", "preferred_username", "email", "email_verified", "tenant",
82
+ "sub", "sid", "name", "preferred_username", "email", "email_verified", "tenant",
56
83
  "picture", Masks::Client::Claims::AVATARS, Masks::Client::Claims::ORGANIZATION,
57
84
  Masks::Client::Claims::ORGANIZATIONS
58
85
  ].freeze
@@ -64,7 +91,8 @@ module Masks
64
91
  end
65
92
 
66
93
  def masks_forget
67
- session.delete(masks_config.session_key)
94
+ masks_session_cookies.clear
95
+ @masks_held = {}
68
96
  @masks_tokens = nil
69
97
  @masked = nil
70
98
  end
@@ -124,22 +152,34 @@ module Masks
124
152
  end
125
153
 
126
154
  def masks_refresh!
127
- return false unless masks_configured?
128
- return false if masks_tokens&.refresh_token.nil?
155
+ return false unless masks_configured? && masks_tokens
129
156
 
130
- refreshed = masks_session.refresh(
131
- masks_tokens.refresh_token,
132
- resource: masks_config.resource_for(request)
133
- )
157
+ spent = masks_tokens.refresh_token
134
158
 
135
- masks_store(refreshed, identity: masks_refreshed_identity(refreshed))
136
- true
137
- rescue Masks::Client::Unregistered
138
- masks_disconnect!
139
- false
140
- rescue Masks::Client::Rejected
141
- masks_forget
142
- false
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
143
183
  end
144
184
 
145
185
  def masks_refreshed_identity(tokens)
@@ -149,22 +189,9 @@ module Masks
149
189
 
150
190
  profile = masks_session.profile(tokens)
151
191
 
152
- profile.empty? ? nil : held.merge(profile.slice(*IDENTITY))
153
- end
154
-
155
- def masks_logout_url(return_to: nil)
156
- return nil unless masks_configured?
192
+ return nil if profile.empty? || profile["sub"].to_s != held["sub"].to_s
157
193
 
158
- masks_session.end_session_url(
159
- post_logout_redirect_uri: masks_post_logout_redirect_uri,
160
- state: SecureRandom.urlsafe_base64(16)
161
- )
162
- rescue Masks::Client::Error
163
- nil
164
- end
165
-
166
- def masks_post_logout_redirect_uri
167
- "#{request.base_url}#{masks_local_path(masks_config.after_sign_out) || '/'}"
194
+ held.merge(profile.slice(*IDENTITY))
168
195
  end
169
196
 
170
197
  def masks_login_url(return_to: nil, organization: nil, privilege: nil)
@@ -193,6 +220,47 @@ module Masks
193
220
 
194
221
  private
195
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
+
246
+ def masks_sign_out_upstream
247
+ return nil unless masks_configured?
248
+
249
+ farewell = masks_farewells.open
250
+
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
258
+ end
259
+
260
+ def masks_after_sign_out_path
261
+ masks_local_path(masks_config.after_sign_out) || "/"
262
+ end
263
+
196
264
  def masks_authorize_session!(wanted)
197
265
  return false unless authenticate_masks!
198
266
 
@@ -253,6 +321,18 @@ module Masks
253
321
  redirect_to Masks::Rails::Engine.routes.url_helpers.start_path
254
322
  end
255
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
+
256
336
  def masks_refuse_json
257
337
  response.headers["Cache-Control"] = "no-store"
258
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
@@ -11,7 +11,7 @@ module Masks
11
11
  :after_sign_out, :session_key, :sign_out_of_issuer, :parent_controller,
12
12
  :credentials_path, :authenticate_everything, :delegates
13
13
  attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget, :logged_out,
14
- :delegation_redirect_uri, :organization, :manages
14
+ :delegation_redirect_uri, :organization, :manages, :cache
15
15
 
16
16
  def initialize
17
17
  @scope = Masks::Client::Session::DEFAULT_SCOPE
@@ -80,18 +80,13 @@ module Masks
80
80
  end
81
81
 
82
82
  def backchannel_logout_uri_for(request)
83
- return nil unless @logged_out.respond_to?(:call)
84
- return nil unless Masks::Client::Issuer.resolve(issuer_for(request)).backchannel_logout?
85
-
86
83
  "#{request.base_url}#{routes.backchannel_logout_path}"
87
84
  end
88
85
 
89
86
  def logged_out!(request, logout)
90
- return false unless @logged_out.respond_to?(:call)
87
+ return unless @logged_out.respond_to?(:call)
91
88
 
92
89
  @logged_out.arity == 1 ? @logged_out.call(logout) : @logged_out.call(request, logout)
93
-
94
- true
95
90
  end
96
91
 
97
92
  def manages?(request, masked = nil)
@@ -139,10 +134,21 @@ module Masks
139
134
  Masks::Client::Resource.new(
140
135
  issuer: issuer_for(request),
141
136
  url: url,
142
- scopes: resource_scopes
137
+ scopes: resource_scopes,
138
+ replay: Cache.new(cache)
143
139
  )
144
140
  end
145
141
 
142
+ def cache
143
+ return @cache if @cache
144
+
145
+ held = ::Rails.cache if ::Rails.respond_to?(:cache)
146
+
147
+ return held unless held.nil? || held.is_a?(ActiveSupport::Cache::NullStore)
148
+
149
+ @process_cache ||= ActiveSupport::Cache::MemoryStore.new
150
+ end
151
+
146
152
  def handshake_for(request)
147
153
  Masks::Client::Handshake.new(
148
154
  issuer_for(request),
@@ -152,7 +158,8 @@ module Masks
152
158
  redirect_uris: [ redirect_uri_for(request), delegation_redirect_uri_for(request) ].compact.uniq,
153
159
  return_to: return_to_for(request),
154
160
  scope: approved_scope,
155
- backchannel_logout_uri: backchannel_logout_uri_for(request)
161
+ backchannel_logout_uri: backchannel_logout_uri_for(request),
162
+ post_logout_redirect_uri: post_logout_redirect_uri_for(request)
156
163
  )
157
164
  end
158
165
 
@@ -189,14 +196,22 @@ module Masks
189
196
  end
190
197
 
191
198
  def return_to_for(request)
192
- uri = URI.parse(redirect_uri_for(request))
193
- port = ":#{uri.port}" unless uri.port == uri.default_port
199
+ "#{origin_for(request)}#{routes.handshake_callback_path}"
200
+ end
194
201
 
195
- "#{uri.scheme}://#{uri.host}#{port}#{routes.handshake_callback_path}"
202
+ def post_logout_redirect_uri_for(request)
203
+ "#{origin_for(request)}#{routes.logout_return_path}"
196
204
  end
197
205
 
198
206
  private
199
207
 
208
+ def origin_for(request)
209
+ uri = URI.parse(redirect_uri_for(request))
210
+ port = ":#{uri.port}" unless uri.port == uri.default_port
211
+
212
+ "#{uri.scheme}://#{uri.host}#{port}"
213
+ end
214
+
200
215
  def routes
201
216
  Masks::Rails::Engine.routes.url_helpers
202
217
  end
@@ -5,6 +5,10 @@ module Masks
5
5
  include Masks::Rails::Configurable
6
6
  include Masks::Rails::Privileged
7
7
 
8
+ included do
9
+ rescue_from Masks::Client::ReplayUnavailable, with: :masks_unavailable if respond_to?(:rescue_from)
10
+ end
11
+
8
12
  def masks_resource
9
13
  @masks_resource ||= masks_config.resource_server_for(request)
10
14
  end
@@ -41,6 +45,15 @@ module Masks
41
45
  }, status: error.status
42
46
  end
43
47
 
48
+ def masks_unavailable
49
+ response.headers["Cache-Control"] = "no-store"
50
+
51
+ render json: {
52
+ "error" => "temporarily_unavailable",
53
+ "error_description" => "the token could not be checked"
54
+ }, status: :service_unavailable
55
+ end
56
+
44
57
  def masks_resource_metadata
45
58
  masks_resource.metadata
46
59
  end
@@ -0,0 +1,54 @@
1
+ module Masks
2
+ module Rails
3
+ class Renewal
4
+ OUTCOMES = %i[renewed refused unregistered unreachable].freeze
5
+
6
+ attr_reader :outcome, :tokens, :identity
7
+
8
+ class << self
9
+ def renewed(tokens, identity)
10
+ new(:renewed, tokens: tokens, identity: identity)
11
+ end
12
+
13
+ def refused
14
+ new(:refused)
15
+ end
16
+
17
+ def unregistered
18
+ new(:unregistered)
19
+ end
20
+
21
+ def unreachable
22
+ new(:unreachable)
23
+ end
24
+ end
25
+
26
+ def initialize(outcome, tokens: nil, identity: nil)
27
+ raise ArgumentError, "#{outcome.inspect} is not one of #{OUTCOMES.join(', ')}" unless OUTCOMES.include?(outcome)
28
+
29
+ @outcome = outcome
30
+ @tokens = tokens
31
+ @identity = identity
32
+ end
33
+
34
+ def to_h
35
+ { "outcome" => outcome.to_s, "tokens" => tokens&.to_h&.except("id_token"), "identity" => identity }
36
+ end
37
+
38
+ def self.from_h(body)
39
+ return unreachable unless body.is_a?(Hash)
40
+
41
+ outcome = OUTCOMES.find { |known| known.to_s == body["outcome"] }
42
+
43
+ return unreachable if outcome.nil? || outcome == :unreachable
44
+ return new(outcome) unless outcome == :renewed
45
+
46
+ tokens = Masks::Client::Tokens.from_h(body["tokens"])
47
+
48
+ return unreachable if tokens.nil? || tokens.access_token.to_s.empty?
49
+
50
+ renewed(tokens, body["identity"].is_a?(Hash) ? body["identity"] : nil)
51
+ end
52
+ end
53
+ end
54
+ end