masks 0.12.1 → 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 +22 -0
- data/app/controllers/masks/rails/logouts_controller.rb +3 -3
- data/app/controllers/masks/rails/sessions_controller.rb +6 -16
- 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 +40 -15
- 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 +19 -8
- data/lib/masks/client/tokens.rb +1 -1
- data/lib/masks/client/transport.rb +1 -1
- data/lib/masks/client/verifier.rb +64 -19
- data/lib/masks/client.rb +3 -0
- data/lib/masks/rails/authentication.rb +12 -9
- 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
|
@@ -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 Unavailable, 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("/")
|
|
@@ -30,10 +37,7 @@ module Masks
|
|
|
30
37
|
named = self.class.normalize(document["issuer"])
|
|
31
38
|
|
|
32
39
|
unless named == url
|
|
33
|
-
raise
|
|
34
|
-
"invalid_issuer",
|
|
35
|
-
"#{url} publishes a document naming #{document['issuer'].inspect}"
|
|
36
|
-
)
|
|
40
|
+
raise Refused.new(*Masks::Client.failure("names_another", url, document["issuer"]))
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
document
|
|
@@ -44,12 +48,16 @@ module Masks
|
|
|
44
48
|
fetch(:jwks) { HTTP.get(endpoint("jwks_uri")) }
|
|
45
49
|
end
|
|
46
50
|
|
|
51
|
+
def keys!
|
|
52
|
+
fetch(:jwks, now: true) { HTTP.get(endpoint("jwks_uri")) }
|
|
53
|
+
end
|
|
54
|
+
|
|
47
55
|
def tenant
|
|
48
56
|
discovery["tenant"]
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
def endpoint(name)
|
|
52
|
-
discovery.fetch(name) { raise
|
|
60
|
+
discovery.fetch(name) { raise Refused.new(*Masks::Client.failure("publishes_nothing", url, name)) }
|
|
53
61
|
end
|
|
54
62
|
|
|
55
63
|
def backchannel_logout?
|
|
@@ -64,7 +72,7 @@ module Masks
|
|
|
64
72
|
wanted = (style || Claims::Avatars::FALLBACK).to_s
|
|
65
73
|
|
|
66
74
|
unless avatar_styles.include?(wanted)
|
|
67
|
-
raise
|
|
75
|
+
raise Refused.new(*Masks::Client.failure("no_avatars", url, wanted))
|
|
68
76
|
end
|
|
69
77
|
|
|
70
78
|
query = size ? "?size=#{size.to_i}" : ""
|
|
@@ -73,24 +81,41 @@ module Masks
|
|
|
73
81
|
end
|
|
74
82
|
|
|
75
83
|
def refresh!
|
|
76
|
-
@lock.synchronize { @cache =
|
|
84
|
+
@lock.synchronize { @cache.each_value { |held| held[:at] = 0 } }
|
|
77
85
|
self
|
|
78
86
|
end
|
|
79
87
|
|
|
80
88
|
private
|
|
81
89
|
|
|
82
|
-
def fetch(key)
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
def fetch(key, now: false, &fetching)
|
|
91
|
+
held = @lock.synchronize { @cache[key] ||= { backoff: FIRST_BACKOFF, retry_at: 0 } }
|
|
92
|
+
clock = Time.now.to_i
|
|
93
|
+
good = held.key?(:value)
|
|
94
|
+
|
|
95
|
+
return held[:value] if good && !now && (held[:at] + @ttl > clock || clock < held[:retry_at])
|
|
96
|
+
raise held[:failure] if clock < held[:retry_at]
|
|
97
|
+
|
|
98
|
+
refetch(held, clock, keep: good && !now, &fetching)
|
|
99
|
+
end
|
|
85
100
|
|
|
101
|
+
def refetch(held, clock, keep:)
|
|
86
102
|
value = yield
|
|
87
103
|
|
|
88
|
-
@lock.synchronize
|
|
104
|
+
@lock.synchronize do
|
|
105
|
+
held.merge!(value: value, at: clock, failure: nil, retry_at: 0, backoff: FIRST_BACKOFF)
|
|
106
|
+
end
|
|
107
|
+
|
|
89
108
|
value
|
|
90
|
-
|
|
109
|
+
rescue Masks::Client::Error => e
|
|
110
|
+
@lock.synchronize do
|
|
111
|
+
held[:failure] = e
|
|
112
|
+
held[:retry_at] = clock + held[:backoff]
|
|
113
|
+
held[:backoff] = [ held[:backoff] * 2, LONGEST_BACKOFF ].min
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
raise e unless keep
|
|
91
117
|
|
|
92
|
-
|
|
93
|
-
entry && entry[:at] + @ttl > Time.now.to_i
|
|
118
|
+
held[:value]
|
|
94
119
|
end
|
|
95
120
|
end
|
|
96
121
|
end
|
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
|
@@ -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
|
|
|
@@ -151,13 +152,13 @@ module Masks
|
|
|
151
152
|
interval = device.interval.positive? ? device.interval : pause
|
|
152
153
|
|
|
153
154
|
loop do
|
|
154
|
-
raise
|
|
155
|
+
raise Refused.new(*Masks::Client.failure("code_expired")) if device.expired?
|
|
155
156
|
|
|
156
157
|
sleep(interval)
|
|
157
158
|
|
|
158
159
|
begin
|
|
159
160
|
return token(form)
|
|
160
|
-
rescue
|
|
161
|
+
rescue Refused => e
|
|
161
162
|
case e.code
|
|
162
163
|
when "authorization_pending" then next
|
|
163
164
|
when "slow_down" then interval += pause
|
|
@@ -215,10 +216,20 @@ 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 +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)
|
|
219
227
|
return nil if tokens.id_token.nil?
|
|
220
228
|
|
|
221
|
-
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
|
+
)
|
|
222
233
|
|
|
223
234
|
profile(tokens).merge(claims)
|
|
224
235
|
end
|
|
@@ -235,7 +246,7 @@ module Masks
|
|
|
235
246
|
|
|
236
247
|
def renew(held, scope:, resource:)
|
|
237
248
|
if held&.refresh_token.to_s.empty?
|
|
238
|
-
raise
|
|
249
|
+
raise Refused.new(*Masks::Client.failure("no_refresh_token"))
|
|
239
250
|
end
|
|
240
251
|
|
|
241
252
|
renewed = refresh(held.refresh_token, scope: scope, resource: resource)
|
|
@@ -249,7 +260,7 @@ module Masks
|
|
|
249
260
|
tokens = Tokens.granted(dpop ? proven(issuer.endpoint("token_endpoint"), form) : post("token_endpoint", form))
|
|
250
261
|
|
|
251
262
|
if dpop && !tokens.bound?
|
|
252
|
-
raise
|
|
263
|
+
raise Refused.new(*Masks::Client.failure("not_held_to_key"))
|
|
253
264
|
end
|
|
254
265
|
|
|
255
266
|
tokens
|
|
@@ -268,7 +279,7 @@ module Masks
|
|
|
268
279
|
|
|
269
280
|
begin
|
|
270
281
|
HTTP.parse(response)
|
|
271
|
-
rescue
|
|
282
|
+
rescue Refused => e
|
|
272
283
|
raise unless e.code == NO_NONCE && wanted && wanted != nonce
|
|
273
284
|
|
|
274
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,33 @@ 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
|
|
9
|
+
AUTH_TIME_LEEWAY = 30
|
|
10
|
+
|
|
11
|
+
def self.recent!(claims, max_age)
|
|
12
|
+
return claims if max_age.nil?
|
|
13
|
+
|
|
14
|
+
signed_in_at = claims["auth_time"]
|
|
15
|
+
allowed = signed_in_at.is_a?(Numeric) && signed_in_at + max_age.to_i + AUTH_TIME_LEEWAY >= Time.now.to_i
|
|
16
|
+
|
|
17
|
+
raise InvalidToken.of("too_old") unless allowed
|
|
18
|
+
|
|
19
|
+
claims
|
|
20
|
+
end
|
|
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
|
|
8
35
|
|
|
9
36
|
attr_reader :issuer, :audience
|
|
10
37
|
|
|
@@ -15,29 +42,40 @@ module Masks
|
|
|
15
42
|
end
|
|
16
43
|
|
|
17
44
|
def verify(token, required: %w[iss sub exp], typ: ID_TOKEN)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 ],
|
|
22
55
|
iss: issuer.url, verify_iss: true,
|
|
23
56
|
aud: audience, verify_aud: true,
|
|
24
|
-
verify_expiration: true,
|
|
25
|
-
|
|
57
|
+
verify_expiration: true, exp_leeway: LEEWAY,
|
|
58
|
+
verify_not_before: true, nbf_leeway: LEEWAY,
|
|
59
|
+
verify_iat: false
|
|
26
60
|
)
|
|
27
61
|
|
|
28
|
-
|
|
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
|
|
29
67
|
|
|
30
68
|
claims
|
|
31
69
|
rescue JWT::ExpiredSignature
|
|
32
|
-
raise InvalidToken
|
|
70
|
+
raise InvalidToken.of("expired")
|
|
71
|
+
rescue JWT::ImmatureSignature
|
|
72
|
+
raise InvalidToken.of("not_valid_yet")
|
|
33
73
|
rescue JWT::InvalidIssuerError
|
|
34
|
-
raise InvalidToken
|
|
74
|
+
raise InvalidToken.of("other_issuer")
|
|
35
75
|
rescue JWT::InvalidAudError
|
|
36
|
-
raise InvalidToken
|
|
37
|
-
rescue JWT::MissingRequiredClaim
|
|
38
|
-
raise InvalidToken, "that token is missing a claim it needs"
|
|
76
|
+
raise InvalidToken.of("other_audience")
|
|
39
77
|
rescue JWT::DecodeError
|
|
40
|
-
raise InvalidToken,
|
|
78
|
+
raise InvalidToken.of("not_signed_by", issuer.url)
|
|
41
79
|
end
|
|
42
80
|
|
|
43
81
|
private
|
|
@@ -47,14 +85,21 @@ module Masks
|
|
|
47
85
|
|
|
48
86
|
return if accepted.include?(named)
|
|
49
87
|
|
|
50
|
-
raise InvalidToken,
|
|
88
|
+
raise InvalidToken.of("wrong_type", held.to_s.empty? ? "nothing" : held, accepted.compact.join(" or "))
|
|
89
|
+
end
|
|
90
|
+
|
|
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
|
|
51
97
|
end
|
|
52
98
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
end
|
|
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" }
|
|
58
103
|
end
|
|
59
104
|
end
|
|
60
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"
|