masks 0.12.0 → 0.12.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22811283549c975e2550ab8546760831275db1efa68ddfd16ebe1dcfb96278a5
4
- data.tar.gz: 888decf6ea332f751cb446a4e7535313527141ed64086cbf62acc2787d320a9b
3
+ metadata.gz: 638e4da3d119c8e1841e1d8f4d87742133ac14ef28c8b1f7dbbdd55725913974
4
+ data.tar.gz: 5c704c90104b9fa581948029a2897e40e1e69f66573739f33aa6c3b7ceae14d4
5
5
  SHA512:
6
- metadata.gz: 33275896c9010d94818885f7a0a3f71b83f6782af7384cf29fb70d482b03492b8e662f27d6f748e1d9a05f453f5294dc910ca5d1492395c7bc3e0bc57aaaea6f
7
- data.tar.gz: 5fb2c1d80a01a0723c3533bc8861cca4c2d0761e9ef5d4ef21f46de41d611835bf9fefe28ac0bab860b65bb0ee166cf8288fbda1a178644f58dc3a850edaea96
6
+ metadata.gz: 97f6edc170dc1544af96fa847f7513a547a39422fffa2435c44f8073bcfaaaf205bf60ed4fc1f1bf82b50c968e9b21e7ff88558fc2042039df5be4ab332b8174
7
+ data.tar.gz: f0c0b5d58b797556ef18dffdf3a81b09e9f94407c68da642950c4578639376a109064b3efbb24d5c802edef0c138a2f079002229f35d9ae520bc2c98ff0c5439
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.2](https://github.com/masksrb/masks/compare/gem/v0.12.1...gem/v0.12.2) (2026-10-20)
4
+
5
+
6
+ ### Fixes
7
+
8
+ * **client:** a sign-in older than the max_age the app asked for is refused ([7116e24](https://github.com/masksrb/masks/commit/7116e24e73dedcca6ce54aa4347100b7d4714e3a)), closes [#161](https://github.com/masksrb/masks/issues/161)
9
+ * **client:** the last good issuer and keys are served while masks is down ([1423d1e](https://github.com/masksrb/masks/commit/1423d1ea6b7968aa66ad2a2ca63180effa4c1002)), closes [#162](https://github.com/masksrb/masks/issues/162)
10
+
11
+ ## [0.12.1](https://github.com/masksrb/masks/compare/gem/v0.12.0...gem/v0.12.1) (2026-10-19)
12
+
13
+
14
+ ### Fixes
15
+
16
+ * **client:** a Rails sign-out link followed by a browser that names no site asks before signing out ([c62c6c1](https://github.com/masksrb/masks/commit/c62c6c1854bb2f7a60339468f6db1b408d6786b9))
17
+ * **client:** one Rails guard decides where a sign-out came from, so a POST that names no site is refused and the Sign out page carries everywhere in its address ([ed86c45](https://github.com/masksrb/masks/commit/ed86c45692dc4d8a0614697ccc832fe1358bd58d))
18
+
3
19
  ## [0.12.0](https://github.com/masksrb/masks/compare/gem/v0.11.0...gem/v0.12.0) (2026-10-14)
4
20
 
5
21
 
@@ -4,8 +4,14 @@ module Masks
4
4
  RESTARTED = "masks_restarted_at".freeze
5
5
  RESTART_WITHIN = 60
6
6
  SAME_ORIGIN = %w[same-origin none].freeze
7
+ CONFIRMATION_HEADERS = {
8
+ "Cache-Control" => "no-store",
9
+ "X-Content-Type-Options" => "nosniff",
10
+ "X-Frame-Options" => "DENY",
11
+ "Content-Security-Policy" => "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
12
+ }.freeze
7
13
 
8
- before_action :refuse_cross_site, only: :destroy
14
+ before_action :require_this_site, only: :destroy
9
15
 
10
16
  def show
11
17
  if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
@@ -29,12 +35,13 @@ module Masks
29
35
  state: pending.id,
30
36
  resource: masks_config.resource_for(request),
31
37
  scope: masks_config.scope_for(params[:privilege]),
38
+ max_age: (params[:max_age].to_i if params[:max_age].to_i.positive?),
32
39
  organization: Masks::Client::Session.organization_key(params[:organization]) ||
33
40
  masks_config.organization_for(request)
34
41
  )
35
42
 
36
43
  masks_requests.amend(
37
- pending.id, nonce: started[:nonce], verifier: started[:verifier]
44
+ pending.id, nonce: started[:nonce], verifier: started[:verifier], max_age: started[:max_age]
38
45
  )
39
46
 
40
47
  redirect_to started[:url], allow_other_host: true
@@ -63,6 +70,10 @@ module Masks
63
70
  return refuse("invalid_nonce", "the id token was issued for another request", pending)
64
71
  end
65
72
 
73
+ unless recent?(identity, pending[:max_age])
74
+ return refuse("invalid_token", "the person signed in longer ago than max_age allows", pending)
75
+ end
76
+
66
77
  renew_session
67
78
  masks_store(tokens, identity: identity)
68
79
 
@@ -99,20 +110,29 @@ module Masks
99
110
 
100
111
  private
101
112
 
102
- def refuse_cross_site
103
- return if same_origin?
113
+ def require_this_site
114
+ from = provenance
115
+ return if from == :this_site
104
116
 
105
- answer("invalid_request", "that request came from another site", :forbidden, failed: "sign-out")
106
- end
117
+ if from == :elsewhere || request.post? || request.delete?
118
+ return answer("invalid_request", "that request came from another site", :forbidden, failed: "sign-out")
119
+ end
107
120
 
108
- def same_origin?
109
- site = request.headers["Sec-Fetch-Site"].to_s
121
+ return answer("invalid_request", "sign out with a POST from this site", :forbidden) if masks_wants_json?
110
122
 
111
- return false unless site.empty? || SAME_ORIGIN.include?(site)
123
+ @action = logout_path(request.query_parameters.slice("everywhere"))
124
+ response.headers.merge!(CONFIRMATION_HEADERS)
125
+ render :confirm, formats: :html
126
+ end
112
127
 
128
+ def provenance
129
+ site = request.headers["Sec-Fetch-Site"].to_s
113
130
  sent = request.headers["Origin"].to_s
114
131
 
115
- sent.empty? || [ request.base_url, own_origin ].include?(sent)
132
+ return :elsewhere unless site.empty? || SAME_ORIGIN.include?(site)
133
+ return :elsewhere unless sent.empty? || [ request.base_url, own_origin ].include?(sent)
134
+
135
+ site.empty? && sent.empty? ? :unproven : :this_site
116
136
  end
117
137
 
118
138
  def own_origin
@@ -141,6 +161,13 @@ module Masks
141
161
  masks_local_path(params[:return_to])
142
162
  end
143
163
 
164
+ def recent?(identity, max_age)
165
+ Masks::Client::Verifier.recent!(identity.is_a?(Hash) ? identity : {}, max_age)
166
+ true
167
+ rescue Masks::Client::InvalidToken
168
+ false
169
+ end
170
+
144
171
  def nonce_matches?(identity, sent)
145
172
  return false if identity == :unverified
146
173
  return true if sent.blank?
@@ -0,0 +1,9 @@
1
+ <% content_for :title, "Sign out" %>
2
+
3
+ <h1>Sign out</h1>
4
+
5
+ <p>Select Sign out to finish signing out.</p>
6
+
7
+ <%= form_with url: @action, method: :post do %>
8
+ <button type="submit">Sign out</button>
9
+ <% end %>
@@ -1,8 +1,15 @@
1
1
  module Masks
2
2
  module Client
3
+ # One issuer's discovery document and keys, fetched once and kept for an
4
+ # hour. While masks cannot be reached, the last good copies are served, a
5
+ # token signed with a key never seen raises Unreachable, and refetches back
6
+ # off from 5 seconds to once a minute.
3
7
  class Issuer
4
8
  DISCOVERY_PATH = "/.well-known/openid-configuration".freeze
5
- TTL = 300
9
+ LIFETIME = 3600
10
+ FIRST_BACKOFF = 5
11
+ LONGEST_BACKOFF = 60
12
+ TTL = LIFETIME
6
13
 
7
14
  def self.normalize(url)
8
15
  url.to_s.chomp("/")
@@ -44,6 +51,10 @@ module Masks
44
51
  fetch(:jwks) { HTTP.get(endpoint("jwks_uri")) }
45
52
  end
46
53
 
54
+ def keys!
55
+ fetch(:jwks, now: true) { HTTP.get(endpoint("jwks_uri")) }
56
+ end
57
+
47
58
  def tenant
48
59
  discovery["tenant"]
49
60
  end
@@ -73,24 +84,41 @@ module Masks
73
84
  end
74
85
 
75
86
  def refresh!
76
- @lock.synchronize { @cache = {} }
87
+ @lock.synchronize { @cache.each_value { |held| held[:at] = 0 } }
77
88
  self
78
89
  end
79
90
 
80
91
  private
81
92
 
82
- def fetch(key)
83
- cached = @lock.synchronize { @cache[key] }
84
- return cached[:value] if fresh?(cached)
93
+ def fetch(key, now: false, &fetching)
94
+ held = @lock.synchronize { @cache[key] ||= { backoff: FIRST_BACKOFF, retry_at: 0 } }
95
+ clock = Time.now.to_i
96
+ good = held.key?(:value)
97
+
98
+ return held[:value] if good && !now && (held[:at] + @ttl > clock || clock < held[:retry_at])
99
+ raise held[:failure] if clock < held[:retry_at]
100
+
101
+ refetch(held, clock, keep: good && !now, &fetching)
102
+ end
85
103
 
104
+ def refetch(held, clock, keep:)
86
105
  value = yield
87
106
 
88
- @lock.synchronize { @cache[key] = { value: value, at: Time.now.to_i } }
107
+ @lock.synchronize do
108
+ held.merge!(value: value, at: clock, failure: nil, retry_at: 0, backoff: FIRST_BACKOFF)
109
+ end
110
+
89
111
  value
90
- end
112
+ rescue Masks::Client::Error => e
113
+ @lock.synchronize do
114
+ held[:failure] = e
115
+ held[:retry_at] = clock + held[:backoff]
116
+ held[:backoff] = [ held[:backoff] * 2, LONGEST_BACKOFF ].min
117
+ end
118
+
119
+ raise e unless keep
91
120
 
92
- def fresh?(entry)
93
- entry && entry[:at] + @ttl > Time.now.to_i
121
+ held[:value]
94
122
  end
95
123
  end
96
124
  end
@@ -60,7 +60,8 @@ module Masks
60
60
  url: "#{issuer.endpoint('authorization_endpoint')}?#{URI.encode_www_form(pairs)}",
61
61
  state: state,
62
62
  nonce: nonce,
63
- verifier: pkce.verifier
63
+ verifier: pkce.verifier,
64
+ max_age: max_age&.to_i
64
65
  }
65
66
  end
66
67
 
@@ -215,10 +216,14 @@ module Masks
215
216
  HTTP.parse(Transport.new(url, tokens: tokens, key: dpop).request("GET", url, limit: HTTP::READ_LIMIT))
216
217
  end
217
218
 
218
- def identity(tokens)
219
+ # The person the +tokens+ belong to: the ID token's claims merged over
220
+ # userinfo. With +max_age+, the seconds the app asked for when it started
221
+ # the sign-in, an ID token whose +auth_time+ is missing or older than
222
+ # that, plus 30 seconds of leeway, raises InvalidToken.
223
+ def identity(tokens, max_age: nil)
219
224
  return nil if tokens.id_token.nil?
220
225
 
221
- claims = Verifier.new(issuer, audience: client_id).verify(tokens.id_token)
226
+ claims = Verifier.recent!(Verifier.new(issuer, audience: client_id).verify(tokens.id_token), max_age)
222
227
 
223
228
  profile(tokens).merge(claims)
224
229
  end
@@ -5,6 +5,18 @@ module Masks
5
5
  ACCESS_TOKEN = "at+jwt".freeze
6
6
  LOGOUT_TOKEN = "logout+jwt".freeze
7
7
  ID_TOKEN = [ "jwt", nil ].freeze
8
+ AUTH_TIME_LEEWAY = 30
9
+
10
+ def self.recent!(claims, max_age)
11
+ return claims if max_age.nil?
12
+
13
+ signed_in_at = claims["auth_time"]
14
+ allowed = signed_in_at.is_a?(Numeric) && signed_in_at + max_age.to_i + AUTH_TIME_LEEWAY >= Time.now.to_i
15
+
16
+ raise InvalidToken, "the person signed in longer ago than max_age allows" unless allowed
17
+
18
+ claims
19
+ end
8
20
 
9
21
  attr_reader :issuer, :audience
10
22
 
@@ -51,10 +63,7 @@ module Masks
51
63
  end
52
64
 
53
65
  def keys
54
- ->(options) do
55
- issuer.refresh! if options[:invalidate]
56
- issuer.jwks
57
- end
66
+ ->(options) { options[:invalidate] ? issuer.keys! : issuer.jwks }
58
67
  end
59
68
  end
60
69
  end
@@ -194,12 +194,15 @@ module Masks
194
194
  held.merge(profile.slice(*IDENTITY))
195
195
  end
196
196
 
197
- def masks_login_url(return_to: nil, organization: nil, privilege: nil)
197
+ # Where to send someone to sign in. +max_age+ asks masks for a sign-in no
198
+ # older than that many seconds; an older one is refused at the callback.
199
+ def masks_login_url(return_to: nil, organization: nil, privilege: nil, max_age: nil)
198
200
  path = Masks::Rails::Engine.routes.url_helpers.start_path
199
201
  query = {
200
202
  "return_to" => masks_local_path(return_to),
201
203
  "organization" => Masks::Client::Session.organization_key(organization),
202
- "privilege" => Array(privilege).join(" ").presence
204
+ "privilege" => Array(privilege).join(" ").presence,
205
+ "max_age" => (max_age.to_i if max_age.to_i.positive?)
203
206
  }.compact
204
207
 
205
208
  query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}"
data/lib/masks/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Masks
2
- VERSION = "0.12.0".freeze
2
+ VERSION = "0.12.2".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: masks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.12.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - the masks authors
@@ -42,6 +42,7 @@ files:
42
42
  - app/views/layouts/masks/rails/plain.html.erb
43
43
  - app/views/masks/rails/handshakes/refused.html.erb
44
44
  - app/views/masks/rails/handshakes/show.html.erb
45
+ - app/views/masks/rails/sessions/confirm.html.erb
45
46
  - config/routes.rb
46
47
  - lib/generators/masks/install/install_generator.rb
47
48
  - lib/generators/masks/install/templates/masks.rb.tt