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.
@@ -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 Unreachable, and refetches back
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 Rejected.new(
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 Rejected.new("invalid_issuer", "#{url} publishes no #{name}") }
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 Rejected.new("invalid_style", "#{url} does not serve #{wanted} avatars")
75
+ raise Refused.new(*Masks::Client.failure("no_avatars", url, wanted))
79
76
  end
80
77
 
81
78
  query = size ? "?size=#{size.to_i}" : ""
@@ -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
@@ -152,13 +152,13 @@ module Masks
152
152
  interval = device.interval.positive? ? device.interval : pause
153
153
 
154
154
  loop do
155
- 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?
156
156
 
157
157
  sleep(interval)
158
158
 
159
159
  begin
160
160
  return token(form)
161
- rescue Rejected => e
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 +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)
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 = Verifier.recent!(Verifier.new(issuer, audience: client_id).verify(tokens.id_token), max_age)
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 Rejected.new("invalid_grant", "there is no refresh token to renew these tokens with")
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 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"))
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 Rejected => e
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))
@@ -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,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, "the person signed in longer ago than max_age allows" unless allowed
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
- claims, header = JWT.decode(
31
- token, nil, true,
32
- algorithms: @algorithms,
33
- 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 ],
34
55
  iss: issuer.url, verify_iss: true,
35
56
  aud: audience, verify_aud: true,
36
- verify_expiration: true,
37
- required_claims: required
57
+ verify_expiration: true, exp_leeway: LEEWAY,
58
+ verify_not_before: true, nbf_leeway: LEEWAY,
59
+ verify_iat: false
38
60
  )
39
61
 
40
- 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
41
67
 
42
68
  claims
43
69
  rescue JWT::ExpiredSignature
44
- raise InvalidToken, "that token has expired"
70
+ raise InvalidToken.of("expired")
71
+ rescue JWT::ImmatureSignature
72
+ raise InvalidToken.of("not_valid_yet")
45
73
  rescue JWT::InvalidIssuerError
46
- raise InvalidToken, "that token was issued by another server"
74
+ raise InvalidToken.of("other_issuer")
47
75
  rescue JWT::InvalidAudError
48
- raise InvalidToken, "that token was issued for another app"
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, "that token was not signed by #{issuer.url}"
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, "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 "))
63
89
  end
64
90
 
65
- def keys
66
- ->(options) { options[:invalidate] ? issuer.keys! : issuer.jwks }
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::Rails::Cache::Unavailable, with: :masks_cache_unavailable
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::Rails::Cache::Unavailable
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::Rejected => e
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
- "insufficient_scope", "this account does not hold the #{missing.join(' and ')} privilege",
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.message },
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
@@ -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, "the cache could not be reached (#{e.class})"
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; end
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