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 +4 -4
- data/CHANGELOG.md +16 -0
- data/app/controllers/masks/rails/sessions_controller.rb +37 -10
- data/app/views/masks/rails/sessions/confirm.html.erb +9 -0
- data/lib/masks/client/issuer.rb +37 -9
- data/lib/masks/client/session.rb +8 -3
- data/lib/masks/client/verifier.rb +13 -4
- data/lib/masks/rails/authentication.rb +5 -2
- data/lib/masks/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 638e4da3d119c8e1841e1d8f4d87742133ac14ef28c8b1f7dbbdd55725913974
|
|
4
|
+
data.tar.gz: 5c704c90104b9fa581948029a2897e40e1e69f66573739f33aa6c3b7ceae14d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 :
|
|
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
|
|
103
|
-
|
|
113
|
+
def require_this_site
|
|
114
|
+
from = provenance
|
|
115
|
+
return if from == :this_site
|
|
104
116
|
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?
|
data/lib/masks/client/issuer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
entry && entry[:at] + @ttl > Time.now.to_i
|
|
121
|
+
held[:value]
|
|
94
122
|
end
|
|
95
123
|
end
|
|
96
124
|
end
|
data/lib/masks/client/session.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
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.
|
|
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
|