masks 0.12.1 → 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: 421d8bafc01e79df7edd7738c30d85a09919fe62dae16944a0d53278fd93f616
4
- data.tar.gz: 9671445e035576d9c89b3d4cdffa28b7a6448b29c23201ad791085c3d30cfa2e
3
+ metadata.gz: 638e4da3d119c8e1841e1d8f4d87742133ac14ef28c8b1f7dbbdd55725913974
4
+ data.tar.gz: 5c704c90104b9fa581948029a2897e40e1e69f66573739f33aa6c3b7ceae14d4
5
5
  SHA512:
6
- metadata.gz: 521d775b39d102648c40f513ccb0d23299ee0af608d882a3eba878d2c8d4d0bd1713fd2a245d74bf725416c62b6e85e9fc596ce11343e29708068d4d31ff1817
7
- data.tar.gz: 535c2e15b8ea0c574a8fca4109e2e6133c0f52991d4764be81ef3a20421142fafe8bb58a6e9d49e3ecc672c168b680e90b7cc73b4412233cab183fffacf65089
6
+ metadata.gz: 97f6edc170dc1544af96fa847f7513a547a39422fffa2435c44f8073bcfaaaf205bf60ed4fc1f1bf82b50c968e9b21e7ff88558fc2042039df5be4ab332b8174
7
+ data.tar.gz: f0c0b5d58b797556ef18dffdf3a81b09e9f94407c68da642950c4578639376a109064b3efbb24d5c802edef0c138a2f079002229f35d9ae520bc2c98ff0c5439
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## [0.12.1](https://github.com/masksrb/masks/compare/gem/v0.12.0...gem/v0.12.1) (2026-10-19)
4
12
 
5
13
 
@@ -35,12 +35,13 @@ module Masks
35
35
  state: pending.id,
36
36
  resource: masks_config.resource_for(request),
37
37
  scope: masks_config.scope_for(params[:privilege]),
38
+ max_age: (params[:max_age].to_i if params[:max_age].to_i.positive?),
38
39
  organization: Masks::Client::Session.organization_key(params[:organization]) ||
39
40
  masks_config.organization_for(request)
40
41
  )
41
42
 
42
43
  masks_requests.amend(
43
- pending.id, nonce: started[:nonce], verifier: started[:verifier]
44
+ pending.id, nonce: started[:nonce], verifier: started[:verifier], max_age: started[:max_age]
44
45
  )
45
46
 
46
47
  redirect_to started[:url], allow_other_host: true
@@ -69,6 +70,10 @@ module Masks
69
70
  return refuse("invalid_nonce", "the id token was issued for another request", pending)
70
71
  end
71
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
+
72
77
  renew_session
73
78
  masks_store(tokens, identity: identity)
74
79
 
@@ -156,6 +161,13 @@ module Masks
156
161
  masks_local_path(params[:return_to])
157
162
  end
158
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
+
159
171
  def nonce_matches?(identity, sent)
160
172
  return false if identity == :unverified
161
173
  return true if sent.blank?
@@ -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.1".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.1
4
+ version: 0.12.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - the masks authors