masks 0.12.2 → 0.13.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 +14 -0
- data/app/controllers/masks/rails/logouts_controller.rb +3 -3
- data/app/controllers/masks/rails/sessions_controller.rb +4 -26
- data/lib/masks/client/claims.rb +4 -9
- data/lib/masks/client/delegations.rb +16 -14
- data/lib/masks/client/device.rb +1 -1
- data/lib/masks/client/embedded.rb +3 -3
- data/lib/masks/client/enrollment.rb +1 -1
- data/lib/masks/client/errors.rb +72 -32
- data/lib/masks/client/failures.rb +81 -0
- data/lib/masks/client/handshake.rb +2 -2
- data/lib/masks/client/http.rb +15 -15
- data/lib/masks/client/id_token.rb +35 -0
- data/lib/masks/client/introspection.rb +2 -2
- data/lib/masks/client/issuer.rb +4 -7
- data/lib/masks/client/logout.rb +6 -12
- data/lib/masks/client/proof.rb +23 -22
- data/lib/masks/client/rack.rb +3 -3
- data/lib/masks/client/registration.rb +1 -1
- data/lib/masks/client/resource.rb +12 -13
- data/lib/masks/client/session.rb +16 -10
- data/lib/masks/client/tokens.rb +1 -1
- data/lib/masks/client/transport.rb +1 -1
- data/lib/masks/client/verifier.rb +53 -17
- data/lib/masks/client.rb +3 -0
- data/lib/masks/rails/authentication.rb +7 -7
- data/lib/masks/rails/cache.rb +6 -8
- data/lib/masks/rails/configuration.rb +5 -1
- data/lib/masks/rails/protected_resource.rb +8 -6
- data/lib/masks/version.rb +1 -1
- metadata +3 -1
data/lib/masks/client/issuer.rb
CHANGED
|
@@ -2,7 +2,7 @@ module Masks
|
|
|
2
2
|
module Client
|
|
3
3
|
# One issuer's discovery document and keys, fetched once and kept for an
|
|
4
4
|
# hour. While masks cannot be reached, the last good copies are served, a
|
|
5
|
-
# token signed with a key never seen raises
|
|
5
|
+
# token signed with a key never seen raises Unavailable, and refetches back
|
|
6
6
|
# off from 5 seconds to once a minute.
|
|
7
7
|
class Issuer
|
|
8
8
|
DISCOVERY_PATH = "/.well-known/openid-configuration".freeze
|
|
@@ -37,10 +37,7 @@ module Masks
|
|
|
37
37
|
named = self.class.normalize(document["issuer"])
|
|
38
38
|
|
|
39
39
|
unless named == url
|
|
40
|
-
raise
|
|
41
|
-
"invalid_issuer",
|
|
42
|
-
"#{url} publishes a document naming #{document['issuer'].inspect}"
|
|
43
|
-
)
|
|
40
|
+
raise Refused.new(*Masks::Client.failure("names_another", url, document["issuer"]))
|
|
44
41
|
end
|
|
45
42
|
|
|
46
43
|
document
|
|
@@ -60,7 +57,7 @@ module Masks
|
|
|
60
57
|
end
|
|
61
58
|
|
|
62
59
|
def endpoint(name)
|
|
63
|
-
discovery.fetch(name) { raise
|
|
60
|
+
discovery.fetch(name) { raise Refused.new(*Masks::Client.failure("publishes_nothing", url, name)) }
|
|
64
61
|
end
|
|
65
62
|
|
|
66
63
|
def backchannel_logout?
|
|
@@ -75,7 +72,7 @@ module Masks
|
|
|
75
72
|
wanted = (style || Claims::Avatars::FALLBACK).to_s
|
|
76
73
|
|
|
77
74
|
unless avatar_styles.include?(wanted)
|
|
78
|
-
raise
|
|
75
|
+
raise Refused.new(*Masks::Client.failure("no_avatars", url, wanted))
|
|
79
76
|
end
|
|
80
77
|
|
|
81
78
|
query = size ? "?size=#{size.to_i}" : ""
|
data/lib/masks/client/logout.rb
CHANGED
|
@@ -3,13 +3,14 @@ module Masks
|
|
|
3
3
|
class Logout
|
|
4
4
|
EVENT = "http://schemas.openid.net/event/backchannel-logout".freeze
|
|
5
5
|
ALGORITHMS = Verifier::ALGORITHMS
|
|
6
|
+
REQUIRED = %w[iss aud iat exp jti].freeze
|
|
6
7
|
LEEWAY = 60
|
|
7
8
|
|
|
8
9
|
class << self
|
|
9
10
|
def verify(token, issuer:, audience:, algorithms: ALGORITHMS)
|
|
10
11
|
held = Verifier
|
|
11
12
|
.new(issuer, audience: audience, algorithms: algorithms)
|
|
12
|
-
.verify(token, required:
|
|
13
|
+
.verify(token, required: REQUIRED, typ: Verifier::LOGOUT_TOKEN)
|
|
13
14
|
|
|
14
15
|
new(held).validate!
|
|
15
16
|
end
|
|
@@ -42,23 +43,16 @@ module Masks
|
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
def validate!
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
raise InvalidToken.of("logout_with_nonce") if claims.key?("nonce")
|
|
47
|
+
raise InvalidToken.of("logout_names_nobody") if subject.to_s.empty? && sid.to_s.empty?
|
|
48
|
+
raise InvalidToken.of("issued_in_future") if issued_at > Time.now.utc + LEEWAY
|
|
48
49
|
|
|
49
50
|
held = claims["events"]
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
refuse!("logout token is not about a logout") unless held[EVENT].is_a?(Hash)
|
|
52
|
+
raise InvalidToken.of("not_a_logout") unless held.is_a?(Hash) && held[EVENT].is_a?(Hash)
|
|
53
53
|
|
|
54
54
|
self
|
|
55
55
|
end
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
def refuse!(said)
|
|
60
|
-
raise InvalidToken, said
|
|
61
|
-
end
|
|
62
56
|
end
|
|
63
57
|
end
|
|
64
58
|
end
|
data/lib/masks/client/proof.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module Masks
|
|
2
2
|
module Client
|
|
3
3
|
class Proof
|
|
4
|
-
|
|
4
|
+
def self.invalid(description)
|
|
5
|
+
InvalidToken.new(description, code: "invalid_dpop_proof")
|
|
6
|
+
end
|
|
5
7
|
|
|
6
8
|
SCHEME = "DPoP".freeze
|
|
7
9
|
TYPE = "dpop+jwt".freeze
|
|
@@ -46,10 +48,10 @@ module Masks
|
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def self.thumbprint(jwk)
|
|
49
|
-
named = THUMBED[jwk["kty"]] || raise(
|
|
51
|
+
named = THUMBED[jwk["kty"]] || raise(Proof.invalid("the proof key is of a kind this resource does not read"))
|
|
50
52
|
held = named.to_h { |field| [ field, jwk[field] ] }
|
|
51
53
|
|
|
52
|
-
raise
|
|
54
|
+
raise Proof.invalid("the proof key is missing part of itself") if held.any? { |_, value| value.to_s.empty? }
|
|
53
55
|
|
|
54
56
|
digest(JSON.generate(held))
|
|
55
57
|
end
|
|
@@ -61,7 +63,7 @@ module Masks
|
|
|
61
63
|
def initialize(proof, method:, url:, replay: nil)
|
|
62
64
|
held = proof.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
63
65
|
|
|
64
|
-
raise
|
|
66
|
+
raise Proof.invalid(Masks::Client.saying("one_proof")) unless held.size == 1
|
|
65
67
|
|
|
66
68
|
@proof = held.first
|
|
67
69
|
@method = method.to_s.upcase
|
|
@@ -74,11 +76,11 @@ module Masks
|
|
|
74
76
|
claims = verified(header)
|
|
75
77
|
thumbprint = self.class.thumbprint(header["jwk"])
|
|
76
78
|
|
|
77
|
-
raise
|
|
79
|
+
raise Proof.invalid(Masks::Client.saying("proof_other_key")) unless same?(thumbprint, jkt)
|
|
78
80
|
|
|
79
81
|
matches!(claims)
|
|
80
82
|
timely!(claims)
|
|
81
|
-
raise
|
|
83
|
+
raise Proof.invalid(Masks::Client.saying("proof_other_token")) unless same?(claims["ath"].to_s, self.class.digest(access_token))
|
|
82
84
|
|
|
83
85
|
once!(claims, thumbprint)
|
|
84
86
|
|
|
@@ -90,64 +92,63 @@ module Masks
|
|
|
90
92
|
def keyed
|
|
91
93
|
header = JWT.decode(@proof, nil, false).last
|
|
92
94
|
|
|
93
|
-
raise
|
|
94
|
-
raise
|
|
95
|
+
raise Proof.invalid(Masks::Client.saying("proof_type", TYPE)) unless header["typ"].to_s.casecmp?(TYPE)
|
|
96
|
+
raise Proof.invalid(Masks::Client.saying("proof_algorithm", header["alg"] || "that")) unless ALGORITHMS.include?(header["alg"].to_s)
|
|
95
97
|
|
|
96
98
|
jwk = header["jwk"]
|
|
97
99
|
|
|
98
|
-
raise
|
|
99
|
-
raise
|
|
100
|
+
raise Proof.invalid(Masks::Client.saying("proof_no_key")) unless jwk.is_a?(Hash)
|
|
101
|
+
raise Proof.invalid(Masks::Client.saying("proof_key_private")) if jwk["kty"].to_s == "oct" || jwk.keys.any? { |field| SECRET.include?(field.to_s) }
|
|
100
102
|
|
|
101
103
|
header
|
|
102
104
|
rescue JWT::DecodeError
|
|
103
|
-
raise
|
|
105
|
+
raise Proof.invalid(Masks::Client.saying("proof_not_a_jwt"))
|
|
104
106
|
end
|
|
105
107
|
|
|
106
108
|
def verified(header)
|
|
107
109
|
JWT.decode(@proof, JWT::JWK.new(header["jwk"]).verify_key, true,
|
|
108
110
|
algorithms: [ header["alg"].to_s ], verify_expiration: false).first
|
|
109
111
|
rescue JWT::DecodeError, JWT::JWKError, OpenSSL::PKey::PKeyError, ArgumentError
|
|
110
|
-
raise
|
|
112
|
+
raise Proof.invalid(Masks::Client.saying("proof_unsigned"))
|
|
111
113
|
end
|
|
112
114
|
|
|
113
115
|
def matches!(claims)
|
|
114
|
-
raise
|
|
115
|
-
raise
|
|
116
|
+
raise Proof.invalid(Masks::Client.saying("proof_other_method")) unless claims["htm"].to_s.upcase == @method
|
|
117
|
+
raise Proof.invalid(Masks::Client.saying("proof_other_url")) unless tidied(claims["htu"]) == tidied(@url)
|
|
116
118
|
end
|
|
117
119
|
|
|
118
120
|
def tidied(value)
|
|
119
121
|
uri = URI.parse(value.to_s)
|
|
120
|
-
raise
|
|
122
|
+
raise Proof.invalid("that proof names no URL") if uri.host.nil?
|
|
121
123
|
|
|
122
124
|
port = ":#{uri.port}" unless uri.port.nil? || uri.port == uri.default_port
|
|
123
125
|
path = uri.path.to_s == "/" ? "" : uri.path.to_s
|
|
124
126
|
|
|
125
127
|
"#{uri.scheme.to_s.downcase}://#{uri.host.downcase}#{port}#{path}"
|
|
126
128
|
rescue URI::InvalidURIError
|
|
127
|
-
raise
|
|
129
|
+
raise Proof.invalid("that proof names a URL this resource cannot read")
|
|
128
130
|
end
|
|
129
131
|
|
|
130
132
|
def timely!(claims)
|
|
131
133
|
made = claims["iat"]
|
|
132
134
|
|
|
133
|
-
raise
|
|
134
|
-
raise
|
|
135
|
-
raise Invalid, "that proof was made in the future" if made > Time.now.to_i + LEEWAY
|
|
135
|
+
raise Proof.invalid("a proof must say when it was made") unless made.is_a?(Numeric)
|
|
136
|
+
raise Proof.invalid(Masks::Client.saying("proof_not_now")) if made < Time.now.to_i - WINDOW - LEEWAY || made > Time.now.to_i + LEEWAY
|
|
136
137
|
end
|
|
137
138
|
|
|
138
139
|
def once!(claims, thumbprint)
|
|
139
140
|
jti = claims["jti"].to_s
|
|
140
141
|
|
|
141
|
-
raise
|
|
142
|
+
raise Proof.invalid(Masks::Client.saying("proof_no_jti")) if jti.empty?
|
|
142
143
|
return if @replay.nil? || first?("#{thumbprint}:#{jti}")
|
|
143
144
|
|
|
144
|
-
raise
|
|
145
|
+
raise Proof.invalid(Masks::Client.saying("proof_replayed"))
|
|
145
146
|
end
|
|
146
147
|
|
|
147
148
|
def first?(key)
|
|
148
149
|
@replay.first?(key, expires_in: MEMORY)
|
|
149
150
|
rescue StandardError => e
|
|
150
|
-
raise
|
|
151
|
+
raise Unavailable.of("replay_store_unreachable")
|
|
151
152
|
end
|
|
152
153
|
|
|
153
154
|
def same?(held, expected)
|
data/lib/masks/client/rack.rb
CHANGED
|
@@ -21,11 +21,11 @@ module Masks
|
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
@app.call(env)
|
|
24
|
-
rescue
|
|
25
|
-
return passthrough(env, e) if @optional && e.is_a?(
|
|
24
|
+
rescue InvalidToken, Forbidden => e
|
|
25
|
+
return passthrough(env, e) if @optional && e.is_a?(InvalidToken) && e.missing?
|
|
26
26
|
|
|
27
27
|
refuse(resource(env), e)
|
|
28
|
-
rescue
|
|
28
|
+
rescue Unavailable
|
|
29
29
|
unavailable
|
|
30
30
|
end
|
|
31
31
|
|
|
@@ -3,7 +3,7 @@ module Masks
|
|
|
3
3
|
class Resource
|
|
4
4
|
PRESENTED = /\A(Bearer|DPoP)[ \t]+([^\s,]+)[ \t]*\z/i.freeze
|
|
5
5
|
METADATA_PATH = "/.well-known/oauth-protected-resource".freeze
|
|
6
|
-
REQUIRED = %w[iss sub exp].freeze
|
|
6
|
+
REQUIRED = %w[iss sub exp jti].freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :issuer, :url, :scopes
|
|
9
9
|
|
|
@@ -29,8 +29,6 @@ module Masks
|
|
|
29
29
|
claims.member!(*Array(role), organization: organization) if role || organization
|
|
30
30
|
|
|
31
31
|
claims
|
|
32
|
-
rescue InvalidToken => e
|
|
33
|
-
raise Unauthorized.new(e.message)
|
|
34
32
|
end
|
|
35
33
|
|
|
36
34
|
def token(authorization)
|
|
@@ -53,16 +51,19 @@ module Masks
|
|
|
53
51
|
end
|
|
54
52
|
|
|
55
53
|
def challenge(error = nil)
|
|
56
|
-
error =
|
|
54
|
+
error = InvalidToken.new(error.to_s) unless error.is_a?(Error)
|
|
55
|
+
missing = error.respond_to?(:missing?) && error.missing?
|
|
56
|
+
dpop = error.respond_to?(:dpop?) && error.dpop?
|
|
57
|
+
scope = error.respond_to?(:scope) ? error.scope : nil
|
|
57
58
|
|
|
58
59
|
parameters = [
|
|
59
|
-
[ "error", error.code ],
|
|
60
|
-
[ "error_description", (error.description
|
|
61
|
-
[ "scope",
|
|
60
|
+
[ "error", (error.code unless missing) ],
|
|
61
|
+
[ "error_description", (error.description unless missing) ],
|
|
62
|
+
[ "scope", scope || (scopes.join(" ") if scopes.any?) ],
|
|
62
63
|
[ "resource_metadata", metadata_url ]
|
|
63
64
|
]
|
|
64
65
|
|
|
65
|
-
scheme =
|
|
66
|
+
scheme = dpop ? "#{Proof::SCHEME} algs=\"#{Proof::ALGORITHMS.join(' ')}\", " : "Bearer "
|
|
66
67
|
|
|
67
68
|
scheme + parameters.filter_map { |name, value|
|
|
68
69
|
%(#{name}="#{quote(value)}") if value && !value.to_s.empty?
|
|
@@ -80,7 +81,7 @@ module Masks
|
|
|
80
81
|
end
|
|
81
82
|
|
|
82
83
|
def presented!(authorization)
|
|
83
|
-
match = authorization.to_s.match(PRESENTED) || raise(
|
|
84
|
+
match = authorization.to_s.match(PRESENTED) || raise(InvalidToken.of("no_token"))
|
|
84
85
|
|
|
85
86
|
[ match[1].casecmp?(Proof::SCHEME) ? :dpop : :bearer, match[2] ]
|
|
86
87
|
end
|
|
@@ -88,13 +89,11 @@ module Masks
|
|
|
88
89
|
def bound!(claims, scheme, token, proof:, method:, url:)
|
|
89
90
|
jkt = claims.dig("cnf", "jkt")
|
|
90
91
|
|
|
91
|
-
raise
|
|
92
|
-
raise
|
|
92
|
+
raise InvalidToken.of("unbound_as_dpop") if jkt.nil? && scheme == :dpop
|
|
93
|
+
raise InvalidToken.new(Masks::Client.saying("bound_as_bearer"), dpop: true) if jkt && scheme != :dpop
|
|
93
94
|
return if jkt.nil?
|
|
94
95
|
|
|
95
96
|
Proof.new(proof, method: method, url: url || self.url, replay: @replay).check!(access_token: token, jkt: jkt)
|
|
96
|
-
rescue Proof::Invalid => e
|
|
97
|
-
raise Unauthorized.new(e.message, code: "invalid_dpop_proof", dpop: true)
|
|
98
97
|
end
|
|
99
98
|
|
|
100
99
|
def origin
|
data/lib/masks/client/session.rb
CHANGED
|
@@ -152,13 +152,13 @@ module Masks
|
|
|
152
152
|
interval = device.interval.positive? ? device.interval : pause
|
|
153
153
|
|
|
154
154
|
loop do
|
|
155
|
-
raise
|
|
155
|
+
raise Refused.new(*Masks::Client.failure("code_expired")) if device.expired?
|
|
156
156
|
|
|
157
157
|
sleep(interval)
|
|
158
158
|
|
|
159
159
|
begin
|
|
160
160
|
return token(form)
|
|
161
|
-
rescue
|
|
161
|
+
rescue Refused => e
|
|
162
162
|
case e.code
|
|
163
163
|
when "authorization_pending" then next
|
|
164
164
|
when "slow_down" then interval += pause
|
|
@@ -217,13 +217,19 @@ module Masks
|
|
|
217
217
|
end
|
|
218
218
|
|
|
219
219
|
# The person the +tokens+ belong to: the ID token's claims merged over
|
|
220
|
-
# userinfo. With +
|
|
221
|
-
#
|
|
222
|
-
#
|
|
223
|
-
|
|
220
|
+
# userinfo. With +nonce+, the one the sign-in started with, an ID token
|
|
221
|
+
# carrying another raises InvalidToken. With +max_age+, the seconds the
|
|
222
|
+
# app asked for when it started the sign-in, an ID token whose
|
|
223
|
+
# +auth_time+ is missing or older than that, plus 30 seconds of leeway,
|
|
224
|
+
# raises InvalidToken. An +at_hash+ that is not the access token's does
|
|
225
|
+
# too.
|
|
226
|
+
def identity(tokens, nonce: nil, max_age: nil)
|
|
224
227
|
return nil if tokens.id_token.nil?
|
|
225
228
|
|
|
226
|
-
claims =
|
|
229
|
+
claims = IdToken.verify(
|
|
230
|
+
tokens.id_token, issuer: issuer, client_id: client_id,
|
|
231
|
+
nonce: nonce, access_token: tokens.access_token, max_age: max_age
|
|
232
|
+
)
|
|
227
233
|
|
|
228
234
|
profile(tokens).merge(claims)
|
|
229
235
|
end
|
|
@@ -240,7 +246,7 @@ module Masks
|
|
|
240
246
|
|
|
241
247
|
def renew(held, scope:, resource:)
|
|
242
248
|
if held&.refresh_token.to_s.empty?
|
|
243
|
-
raise
|
|
249
|
+
raise Refused.new(*Masks::Client.failure("no_refresh_token"))
|
|
244
250
|
end
|
|
245
251
|
|
|
246
252
|
renewed = refresh(held.refresh_token, scope: scope, resource: resource)
|
|
@@ -254,7 +260,7 @@ module Masks
|
|
|
254
260
|
tokens = Tokens.granted(dpop ? proven(issuer.endpoint("token_endpoint"), form) : post("token_endpoint", form))
|
|
255
261
|
|
|
256
262
|
if dpop && !tokens.bound?
|
|
257
|
-
raise
|
|
263
|
+
raise Refused.new(*Masks::Client.failure("not_held_to_key"))
|
|
258
264
|
end
|
|
259
265
|
|
|
260
266
|
tokens
|
|
@@ -273,7 +279,7 @@ module Masks
|
|
|
273
279
|
|
|
274
280
|
begin
|
|
275
281
|
HTTP.parse(response)
|
|
276
|
-
rescue
|
|
282
|
+
rescue Refused => e
|
|
277
283
|
raise unless e.code == NO_NONCE && wanted && wanted != nonce
|
|
278
284
|
|
|
279
285
|
response = HTTP.deliver(proven_request(url, form, wanted))
|
data/lib/masks/client/tokens.rb
CHANGED
|
@@ -82,7 +82,7 @@ module Masks
|
|
|
82
82
|
request = verb.new(uri, HTTP.default_headers.merge(headers))
|
|
83
83
|
|
|
84
84
|
if held.bound?
|
|
85
|
-
raise
|
|
85
|
+
raise Refused.new("invalid_request", "a DPoP token needs the key it is held to") if key.nil?
|
|
86
86
|
|
|
87
87
|
request["Authorization"] = "#{Proof::SCHEME} #{held.access_token}"
|
|
88
88
|
request["DPoP"] = key.proof(request.method, uri, access_token: held.access_token, nonce: nonce)
|
|
@@ -5,6 +5,7 @@ module Masks
|
|
|
5
5
|
ACCESS_TOKEN = "at+jwt".freeze
|
|
6
6
|
LOGOUT_TOKEN = "logout+jwt".freeze
|
|
7
7
|
ID_TOKEN = [ "jwt", nil ].freeze
|
|
8
|
+
LEEWAY = 30
|
|
8
9
|
AUTH_TIME_LEEWAY = 30
|
|
9
10
|
|
|
10
11
|
def self.recent!(claims, max_age)
|
|
@@ -13,11 +14,25 @@ module Masks
|
|
|
13
14
|
signed_in_at = claims["auth_time"]
|
|
14
15
|
allowed = signed_in_at.is_a?(Numeric) && signed_in_at + max_age.to_i + AUTH_TIME_LEEWAY >= Time.now.to_i
|
|
15
16
|
|
|
16
|
-
raise InvalidToken
|
|
17
|
+
raise InvalidToken.of("too_old") unless allowed
|
|
17
18
|
|
|
18
19
|
claims
|
|
19
20
|
end
|
|
20
21
|
|
|
22
|
+
def self.header(token)
|
|
23
|
+
segments = token.to_s.split(".", -1)
|
|
24
|
+
|
|
25
|
+
raise InvalidToken.of("not_a_jwt") unless segments.size == 3
|
|
26
|
+
|
|
27
|
+
held = JSON.parse(Base64.urlsafe_decode64(segments.first))
|
|
28
|
+
|
|
29
|
+
raise InvalidToken.of("not_a_jwt") unless held.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
held
|
|
32
|
+
rescue ArgumentError, JSON::ParserError
|
|
33
|
+
raise InvalidToken.of("not_a_jwt")
|
|
34
|
+
end
|
|
35
|
+
|
|
21
36
|
attr_reader :issuer, :audience
|
|
22
37
|
|
|
23
38
|
def initialize(issuer, audience:, algorithms: ALGORITHMS)
|
|
@@ -27,29 +42,40 @@ module Masks
|
|
|
27
42
|
end
|
|
28
43
|
|
|
29
44
|
def verify(token, required: %w[iss sub exp], typ: ID_TOKEN)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
header = self.class.header(token)
|
|
46
|
+
algorithm = header["alg"].to_s
|
|
47
|
+
|
|
48
|
+
raise InvalidToken.of("not_an_algorithm", algorithm) unless @algorithms.include?(algorithm)
|
|
49
|
+
|
|
50
|
+
typed!(header["typ"], Array(typ))
|
|
51
|
+
|
|
52
|
+
claims, = JWT.decode(
|
|
53
|
+
token, key_for(header["kid"]), true,
|
|
54
|
+
algorithms: [ algorithm ],
|
|
34
55
|
iss: issuer.url, verify_iss: true,
|
|
35
56
|
aud: audience, verify_aud: true,
|
|
36
|
-
verify_expiration: true,
|
|
37
|
-
|
|
57
|
+
verify_expiration: true, exp_leeway: LEEWAY,
|
|
58
|
+
verify_not_before: true, nbf_leeway: LEEWAY,
|
|
59
|
+
verify_iat: false
|
|
38
60
|
)
|
|
39
61
|
|
|
40
|
-
|
|
62
|
+
Array(required).each { |name| raise InvalidToken.of("missing_claims", name) unless claims.key?(name) }
|
|
63
|
+
|
|
64
|
+
issued = claims["iat"]
|
|
65
|
+
|
|
66
|
+
raise InvalidToken.of("issued_in_future") if issued.is_a?(Numeric) && issued > Time.now.to_i + LEEWAY
|
|
41
67
|
|
|
42
68
|
claims
|
|
43
69
|
rescue JWT::ExpiredSignature
|
|
44
|
-
raise InvalidToken
|
|
70
|
+
raise InvalidToken.of("expired")
|
|
71
|
+
rescue JWT::ImmatureSignature
|
|
72
|
+
raise InvalidToken.of("not_valid_yet")
|
|
45
73
|
rescue JWT::InvalidIssuerError
|
|
46
|
-
raise InvalidToken
|
|
74
|
+
raise InvalidToken.of("other_issuer")
|
|
47
75
|
rescue JWT::InvalidAudError
|
|
48
|
-
raise InvalidToken
|
|
49
|
-
rescue JWT::MissingRequiredClaim
|
|
50
|
-
raise InvalidToken, "that token is missing a claim it needs"
|
|
76
|
+
raise InvalidToken.of("other_audience")
|
|
51
77
|
rescue JWT::DecodeError
|
|
52
|
-
raise InvalidToken,
|
|
78
|
+
raise InvalidToken.of("not_signed_by", issuer.url)
|
|
53
79
|
end
|
|
54
80
|
|
|
55
81
|
private
|
|
@@ -59,11 +85,21 @@ module Masks
|
|
|
59
85
|
|
|
60
86
|
return if accepted.include?(named)
|
|
61
87
|
|
|
62
|
-
raise InvalidToken,
|
|
88
|
+
raise InvalidToken.of("wrong_type", held.to_s.empty? ? "nothing" : held, accepted.compact.join(" or "))
|
|
63
89
|
end
|
|
64
90
|
|
|
65
|
-
def
|
|
66
|
-
|
|
91
|
+
def key_for(kid)
|
|
92
|
+
found = published(issuer.jwks, kid) || published(issuer.keys!, kid)
|
|
93
|
+
|
|
94
|
+
raise InvalidToken.of("no_key", issuer.url, kid.to_s) if found.nil?
|
|
95
|
+
|
|
96
|
+
JWT::JWK.import(found).verify_key
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def published(jwks, kid)
|
|
100
|
+
keys = jwks.is_a?(Hash) ? Array(jwks["keys"]) : []
|
|
101
|
+
|
|
102
|
+
keys.find { |key| key.is_a?(Hash) && key["kid"] == kid && key.fetch("use", "sig") == "sig" }
|
|
67
103
|
end
|
|
68
104
|
end
|
|
69
105
|
end
|
data/lib/masks/client.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "base64"
|
|
2
|
+
require "digest"
|
|
2
3
|
require "ipaddr"
|
|
3
4
|
require "json"
|
|
4
5
|
require "jwt"
|
|
@@ -8,6 +9,7 @@ require "securerandom"
|
|
|
8
9
|
require "uri"
|
|
9
10
|
|
|
10
11
|
require_relative "version"
|
|
12
|
+
require_relative "client/failures"
|
|
11
13
|
require_relative "client/errors"
|
|
12
14
|
require_relative "client/http"
|
|
13
15
|
require_relative "client/embedded"
|
|
@@ -26,6 +28,7 @@ require_relative "client/device"
|
|
|
26
28
|
require_relative "client/transport"
|
|
27
29
|
require_relative "client/session"
|
|
28
30
|
require_relative "client/verifier"
|
|
31
|
+
require_relative "client/id_token"
|
|
29
32
|
require_relative "client/proof"
|
|
30
33
|
require_relative "client/logout"
|
|
31
34
|
require_relative "client/resource"
|
|
@@ -11,7 +11,7 @@ module Masks
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
if respond_to?(:rescue_from) && method_defined?(:render)
|
|
14
|
-
rescue_from Masks::
|
|
14
|
+
rescue_from Masks::Client::Unavailable, with: :masks_cache_unavailable
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -84,10 +84,10 @@ module Masks
|
|
|
84
84
|
Masks::Client::Claims::ORGANIZATIONS
|
|
85
85
|
].freeze
|
|
86
86
|
|
|
87
|
-
def masks_identity_from(tokens)
|
|
87
|
+
def masks_identity_from(tokens, nonce: nil, max_age: nil)
|
|
88
88
|
return nil if tokens.id_token.nil?
|
|
89
89
|
|
|
90
|
-
masks_session.identity(tokens)
|
|
90
|
+
masks_session.identity(tokens, nonce: nonce, max_age: max_age)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
def masks_forget
|
|
@@ -163,7 +163,7 @@ module Masks
|
|
|
163
163
|
|
|
164
164
|
renewal = begin
|
|
165
165
|
masks_cache.renew(masks_config.issuer_for(request).to_s, spent) { masks_renewal(spent) }
|
|
166
|
-
rescue Masks::
|
|
166
|
+
rescue Masks::Client::Unavailable
|
|
167
167
|
Masks::Rails::Renewal.unreachable
|
|
168
168
|
end
|
|
169
169
|
|
|
@@ -240,7 +240,7 @@ module Masks
|
|
|
240
240
|
Masks::Rails::Renewal.renewed(refreshed, masks_refreshed_identity(refreshed))
|
|
241
241
|
rescue Masks::Client::Unregistered
|
|
242
242
|
Masks::Rails::Renewal.unregistered
|
|
243
|
-
rescue Masks::Client::
|
|
243
|
+
rescue Masks::Client::Refused => e
|
|
244
244
|
e.status.to_i >= 500 || e.status.to_i == 429 ? Masks::Rails::Renewal.unreachable : Masks::Rails::Renewal.refused
|
|
245
245
|
rescue Masks::Client::Error
|
|
246
246
|
Masks::Rails::Renewal.unreachable
|
|
@@ -274,7 +274,7 @@ module Masks
|
|
|
274
274
|
|
|
275
275
|
if masks_asked_already?(session.delete(STEPPED_UP), missing)
|
|
276
276
|
raise Masks::Client::Forbidden.new(
|
|
277
|
-
"
|
|
277
|
+
*Masks::Client.failure("no_privilege", missing.join(" and ")),
|
|
278
278
|
scope: missing.flat_map { |name| masks_config.privilege(name).scopes }.join(" ")
|
|
279
279
|
)
|
|
280
280
|
end
|
|
@@ -329,7 +329,7 @@ module Masks
|
|
|
329
329
|
response.headers["Retry-After"] = "5"
|
|
330
330
|
|
|
331
331
|
if masks_wants_json?
|
|
332
|
-
render json: { "error" => "temporarily_unavailable", "error_description" => error.
|
|
332
|
+
render json: { "error" => "temporarily_unavailable", "error_description" => error.description },
|
|
333
333
|
status: :service_unavailable
|
|
334
334
|
else
|
|
335
335
|
render plain: "Signing in is unavailable right now. Try again in a moment.", status: :service_unavailable
|
data/lib/masks/rails/cache.rb
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
module Masks
|
|
2
2
|
module Rails
|
|
3
3
|
class Cache
|
|
4
|
-
class Unavailable < Masks::Client::Error; end
|
|
5
|
-
|
|
6
4
|
CLAIM = 30
|
|
7
5
|
RESULT = 60
|
|
8
6
|
WAIT = 0.05
|
|
@@ -41,7 +39,7 @@ module Masks
|
|
|
41
39
|
|
|
42
40
|
kept = begin
|
|
43
41
|
write(self.class.key("departed", issuer, kind, value), self.class.now, expires_in: DEPARTURE)
|
|
44
|
-
rescue Unavailable
|
|
42
|
+
rescue Masks::Client::Unavailable
|
|
45
43
|
false
|
|
46
44
|
end
|
|
47
45
|
|
|
@@ -49,7 +47,7 @@ module Masks
|
|
|
49
47
|
|
|
50
48
|
forgotten { delete(self.class.key("logout", issuer, logout.jti)) }
|
|
51
49
|
|
|
52
|
-
raise Unavailable, "the cache did not keep that logout"
|
|
50
|
+
raise Masks::Client::Unavailable, "the cache did not keep that logout"
|
|
53
51
|
end
|
|
54
52
|
|
|
55
53
|
def departed?(issuer, identity, since:)
|
|
@@ -87,7 +85,7 @@ module Masks
|
|
|
87
85
|
return true if write(key, 1, unless_exist: true, expires_in: expires_in)
|
|
88
86
|
return false unless read(key).nil?
|
|
89
87
|
|
|
90
|
-
raise Unavailable, "the cache neither kept that key nor holds it, so it is not answering"
|
|
88
|
+
raise Masks::Client::Unavailable, "the cache neither kept that key nor holds it, so it is not answering"
|
|
91
89
|
end
|
|
92
90
|
|
|
93
91
|
def read(key)
|
|
@@ -104,15 +102,15 @@ module Masks
|
|
|
104
102
|
|
|
105
103
|
def reached
|
|
106
104
|
yield
|
|
107
|
-
rescue Unavailable
|
|
105
|
+
rescue Masks::Client::Unavailable
|
|
108
106
|
raise
|
|
109
107
|
rescue StandardError => e
|
|
110
|
-
raise Unavailable, "
|
|
108
|
+
raise Masks::Client::Unavailable, Masks::Client.saying("store_unreachable")
|
|
111
109
|
end
|
|
112
110
|
|
|
113
111
|
def forgotten
|
|
114
112
|
yield
|
|
115
|
-
rescue Unavailable
|
|
113
|
+
rescue Masks::Client::Unavailable
|
|
116
114
|
nil
|
|
117
115
|
end
|
|
118
116
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
module Masks
|
|
2
2
|
module Rails
|
|
3
3
|
class Configuration
|
|
4
|
-
class Unconfigured < Masks::Client::Error
|
|
4
|
+
class Unconfigured < Masks::Client::Error
|
|
5
|
+
def initialize(description)
|
|
6
|
+
super("not_configured", description)
|
|
7
|
+
end
|
|
8
|
+
end
|
|
5
9
|
|
|
6
10
|
Privilege = Struct.new(:name, :scopes, :roles, :organization, keyword_init: true)
|
|
7
11
|
PRIVILEGE_KEYS = %w[scope role organization].freeze
|