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.
@@ -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
- 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("/")
@@ -30,10 +37,7 @@ module Masks
30
37
  named = self.class.normalize(document["issuer"])
31
38
 
32
39
  unless named == url
33
- raise Rejected.new(
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 Rejected.new("invalid_issuer", "#{url} publishes no #{name}") }
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 Rejected.new("invalid_style", "#{url} does not serve #{wanted} avatars")
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
- cached = @lock.synchronize { @cache[key] }
84
- return cached[:value] if fresh?(cached)
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 { @cache[key] = { value: value, at: Time.now.to_i } }
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
- end
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
- def fresh?(entry)
93
- entry && entry[:at] + @ttl > Time.now.to_i
118
+ held[:value]
94
119
  end
95
120
  end
96
121
  end
@@ -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: %w[iss aud iat exp jti events], typ: Verifier::LOGOUT_TOKEN)
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
- refuse!("logout token carries a nonce, so it is an id token") if claims.key?("nonce")
46
- refuse!("logout token names neither a subject nor a session") if subject.nil? && sid.nil?
47
- refuse!("logout token was issued in the future") if issued_at > Time.now.utc + LEEWAY
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
- refuse!("logout token has no events claim") unless held.is_a?(Hash)
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
@@ -1,7 +1,9 @@
1
1
  module Masks
2
2
  module Client
3
3
  class Proof
4
- class Invalid < Error; end
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(Invalid, "the proof key is of a kind this resource does not read")
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 Invalid, "the proof key is missing part of itself" if held.any? { |_, value| value.to_s.empty? }
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 Invalid, "exactly one DPoP proof is required" unless held.size == 1
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 Invalid, "that proof was made with another key than the token is bound to" unless same?(thumbprint, jkt)
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 Invalid, "that proof was made for another token" unless same?(claims["ath"].to_s, self.class.digest(access_token))
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 Invalid, "a proof must be typed #{TYPE}" unless header["typ"].to_s.casecmp?(TYPE)
94
- raise Invalid, "#{header['alg'] || 'that'} is not an algorithm a proof may use" unless ALGORITHMS.include?(header["alg"].to_s)
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 Invalid, "a proof must carry the public key that signed it" unless jwk.is_a?(Hash)
99
- raise Invalid, "a proof key must be public" if jwk["kty"].to_s == "oct" || jwk.keys.any? { |field| SECRET.include?(field.to_s) }
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 Invalid, "that proof is not a readable JWT"
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 Invalid, "that proof was not signed by the key it carries"
112
+ raise Proof.invalid(Masks::Client.saying("proof_unsigned"))
111
113
  end
112
114
 
113
115
  def matches!(claims)
114
- raise Invalid, "that proof was made for another method" unless claims["htm"].to_s.upcase == @method
115
- raise Invalid, "that proof was made for another URL" unless tidied(claims["htu"]) == tidied(@url)
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 Invalid, "that proof names no URL" if uri.host.nil?
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 Invalid, "that proof names a URL this resource cannot read"
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 Invalid, "a proof must say when it was made" unless made.is_a?(Numeric)
134
- raise Invalid, "that proof was made too long ago" if made < Time.now.to_i - WINDOW - LEEWAY
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 Invalid, "a proof must carry a jti" if jti.empty?
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 Invalid, "that proof has already been used"
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 ReplayUnavailable, "the replay store could not say whether that proof is new (#{e.class})"
151
+ raise Unavailable.of("replay_store_unreachable")
151
152
  end
152
153
 
153
154
  def same?(held, expected)
@@ -21,11 +21,11 @@ module Masks
21
21
  )
22
22
 
23
23
  @app.call(env)
24
- rescue Challenge => e
25
- return passthrough(env, e) if @optional && e.is_a?(Unauthenticated)
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 ReplayUnavailable
28
+ rescue Unavailable
29
29
  unavailable
30
30
  end
31
31
 
@@ -121,7 +121,7 @@ module Masks
121
121
  yield
122
122
  rescue Unregistered
123
123
  raise
124
- rescue Rejected => e
124
+ rescue Refused => e
125
125
  raise unless statuses.include?(e.status)
126
126
 
127
127
  raise Unregistered.new(Unregistered::CODE, e.description, status: e.status)
@@ -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 = Unauthorized.new(error.to_s) unless error.is_a?(Challenge)
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 if error.code) ],
61
- [ "scope", error.scope || (scopes.join(" ") if scopes.any?) ],
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 = error.dpop? ? "#{Proof::SCHEME} algs=\"#{Proof::ALGORITHMS.join(' ')}\", " : "Bearer "
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(Unauthenticated.new)
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 Unauthorized.new("that token is not bound to a key, so it is presented as Bearer") if jkt.nil? && scheme == :dpop
92
- raise Unauthorized.new("that token is bound to a key, so it is presented as DPoP with a proof", dpop: true) if jkt && scheme != :dpop
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
@@ -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 Rejected.new("expired_token", "the code expired before anyone approved it") if device.expired?
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 Rejected => e
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
- def identity(tokens)
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 = Verifier.new(issuer, audience: client_id).verify(tokens.id_token)
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 Rejected.new("invalid_grant", "there is no refresh token to renew these tokens with")
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 Rejected.new("invalid_dpop_proof", "the token endpoint answered with a token that is not held to this key")
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 Rejected => e
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))
@@ -12,7 +12,7 @@ module Masks
12
12
  token = new(body)
13
13
 
14
14
  if token.access_token.to_s.empty?
15
- raise Rejected.new(
15
+ raise Refused.new(
16
16
  body["error"] || "invalid_token_response",
17
17
  body["error_description"] || "the token endpoint answered without an access_token"
18
18
  )
@@ -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 Error, "a DPoP token needs the key it is held to" if key.nil?
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
- claims, header = JWT.decode(
19
- token, nil, true,
20
- algorithms: @algorithms,
21
- jwks: keys,
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
- required_claims: required
57
+ verify_expiration: true, exp_leeway: LEEWAY,
58
+ verify_not_before: true, nbf_leeway: LEEWAY,
59
+ verify_iat: false
26
60
  )
27
61
 
28
- typed!(header["typ"], Array(typ))
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, "that token has expired"
70
+ raise InvalidToken.of("expired")
71
+ rescue JWT::ImmatureSignature
72
+ raise InvalidToken.of("not_valid_yet")
33
73
  rescue JWT::InvalidIssuerError
34
- raise InvalidToken, "that token was issued by another server"
74
+ raise InvalidToken.of("other_issuer")
35
75
  rescue JWT::InvalidAudError
36
- raise InvalidToken, "that token was issued for another app"
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, "that token was not signed by #{issuer.url}"
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, "that token is typed #{held.inspect}, not #{accepted.compact.join(' or ')}"
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 keys
54
- ->(options) do
55
- issuer.refresh! if options[:invalidate]
56
- issuer.jwks
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"