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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 638e4da3d119c8e1841e1d8f4d87742133ac14ef28c8b1f7dbbdd55725913974
4
- data.tar.gz: 5c704c90104b9fa581948029a2897e40e1e69f66573739f33aa6c3b7ceae14d4
3
+ metadata.gz: 24592fe5fb130abf36cb52a58a03c8184d55c428045f1698d3c061143ccffe06
4
+ data.tar.gz: b5bea82b253704173650c494018a16e1d5eb105fd0c3511c7d8c46141f1fe3cc
5
5
  SHA512:
6
- metadata.gz: 97f6edc170dc1544af96fa847f7513a547a39422fffa2435c44f8073bcfaaaf205bf60ed4fc1f1bf82b50c968e9b21e7ff88558fc2042039df5be4ab332b8174
7
- data.tar.gz: f0c0b5d58b797556ef18dffdf3a81b09e9f94407c68da642950c4578639376a109064b3efbb24d5c802edef0c138a2f079002229f35d9ae520bc2c98ff0c5439
6
+ metadata.gz: d154be9eb9bbb48b980fbacbd4d55b3bb9566e6375ff13619731a79c66730c923e743c7ce854f355b78d94ecc00e0ae5721b7d588d96c66ca01300e3580cd7a4
7
+ data.tar.gz: 19c8607bec23746660dbf8afc01a4d1ebb3a8cb11496a2f83d6a98f5ac93b121ac6cce4c6290e54a53d828e90665fb97f752ff74f71901713e1c7ca513699984
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.0](https://github.com/masksrb/masks/compare/gem/v0.12.2...gem/v0.13.0) (2026-11-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * **client:** the Ruby client checks every token against the shared contract, and an ID token's nonce, authorized party, at_hash, and max_age are checked where it is verified ([6025b73](https://github.com/masksrb/masks/commit/6025b736ef83819890dd8f6372293fd993ce328e))
9
+ * **client:** the Ruby client raises Refused, InvalidToken, Forbidden, Unavailable, Insecure, and Foreign, the names every other SDK uses ([7d78435](https://github.com/masksrb/masks/commit/7d78435eed9b40f8da156c89890c104f23f3b70b))
10
+ * **client:** the Ruby client words every failure from the shared error table, and a refusal or a challenge reads masks: code: description ([9674a55](https://github.com/masksrb/masks/commit/9674a5543d8154830330f49e4ba20da886d42e5e))
11
+
12
+
13
+ ### Fixes
14
+
15
+ * **client:** a back-channel logout that the cache cannot keep answers JSON again, and every error_description reads without the masks: code: prefix ([30fbdec](https://github.com/masksrb/masks/commit/30fbdec0796fc5ebc7f85e421a7609267729ab45))
16
+
3
17
  ## [0.12.2](https://github.com/masksrb/masks/compare/gem/v0.12.1...gem/v0.12.2) (2026-10-20)
4
18
 
5
19
 
@@ -18,13 +18,13 @@ module Masks
18
18
 
19
19
  head :ok
20
20
  rescue Masks::Client::InvalidToken, Masks::Rails::Configuration::Unconfigured => e
21
- refuse("invalid_request", e.message)
22
- rescue Masks::Client::Unreachable, Masks::Rails::Cache::Unavailable => e
23
- refuse("temporarily_unavailable", e.message, status: :service_unavailable)
21
+ refuse("invalid_request", e.description)
24
22
  end
25
23
 
26
24
  private
27
25
 
26
+ def masks_wants_json? = true
27
+
28
28
  def refuse(code, description, status: :bad_request)
29
29
  response.headers["Cache-Control"] = "no-store"
30
30
 
@@ -61,18 +61,12 @@ module Masks
61
61
  )
62
62
 
63
63
  identity = begin
64
- masks_identity_from(tokens)
65
- rescue Masks::Client::InvalidToken
66
- :unverified
64
+ masks_identity_from(tokens, nonce: pending[:nonce], max_age: pending[:max_age])
65
+ rescue Masks::Client::InvalidToken => e
66
+ return refuse(e.code, e.description, pending)
67
67
  end
68
68
 
69
- unless nonce_matches?(identity, pending[:nonce])
70
- return refuse("invalid_nonce", "the id token was issued for another request", pending)
71
- end
72
-
73
- unless recent?(identity, pending[:max_age])
74
- return refuse("invalid_token", "the person signed in longer ago than max_age allows", pending)
75
- end
69
+ return refuse(*Masks::Client.failure("no_id_token"), pending) if pending[:nonce].present? && identity.nil?
76
70
 
77
71
  renew_session
78
72
  masks_store(tokens, identity: identity)
@@ -161,22 +155,6 @@ module Masks
161
155
  masks_local_path(params[:return_to])
162
156
  end
163
157
 
164
- def recent?(identity, max_age)
165
- Masks::Client::Verifier.recent!(identity.is_a?(Hash) ? identity : {}, max_age)
166
- true
167
- rescue Masks::Client::InvalidToken
168
- false
169
- end
170
-
171
- def nonce_matches?(identity, sent)
172
- return false if identity == :unverified
173
- return true if sent.blank?
174
- return false unless identity.is_a?(Hash)
175
-
176
- held = identity["nonce"].to_s
177
- held.present? && ActiveSupport::SecurityUtils.secure_compare(held, sent)
178
- end
179
-
180
158
  def stale
181
159
  return redirect_to(masks_config.after_sign_in) if masks_signed_in? || (masks_tokens && masks_refresh!)
182
160
  return restart unless masks_wants_json? || restarted_lately?
@@ -160,17 +160,16 @@ module Masks
160
160
  held = self.organization
161
161
 
162
162
  unless held.present?
163
- raise Forbidden.new("insufficient_organization", "this token names no organization",
164
- scope: Session::ORGANIZATION)
163
+ raise Forbidden.new(*Masks::Client.failure("no_organization"), scope: Session::ORGANIZATION)
165
164
  end
166
165
 
167
166
  if organization && held.key != organization.to_s && held.id != organization.to_s
168
- raise Forbidden.new("insufficient_organization", "this token was issued for #{held.key}, not #{organization}")
167
+ raise Forbidden.new(*Masks::Client.failure("other_organization", held.key, organization))
169
168
  end
170
169
 
171
170
  return held if roles.flatten.empty? || held.role?(*roles)
172
171
 
173
- raise Forbidden.new("insufficient_role", "#{held.role} in #{held.key} is not #{roles.flatten.join(' or ')}")
172
+ raise Forbidden.new(*Masks::Client.failure("other_role", held.role, held.key, roles.flatten.join(" or ")))
174
173
  end
175
174
 
176
175
  def picture
@@ -188,11 +187,7 @@ module Masks
188
187
  def permit!(scope)
189
188
  return true if permits?(scope)
190
189
 
191
- raise Forbidden.new(
192
- "insufficient_scope",
193
- "this token does not carry #{scope}",
194
- scope: scope.to_s
195
- )
190
+ raise Forbidden.new(*Masks::Client.failure("no_scope", scope), scope: scope.to_s)
196
191
  end
197
192
 
198
193
  def expires_at
@@ -7,14 +7,16 @@ module Masks
7
7
  login_required interaction_required consent_required invalid_scope].freeze
8
8
 
9
9
  class Refused < Error
10
- attr_reader :code, :description, :secret
10
+ attr_reader :secret
11
11
 
12
- def initialize(code, description, secret: nil)
13
- super([ code, description ].compact.join(": "))
12
+ def self.of(key, *values)
13
+ new(*Masks::Client.failure(key, *values))
14
+ end
14
15
 
15
- @code = code
16
- @description = description
16
+ def initialize(code, description, status: nil, secret: nil)
17
17
  @secret = secret
18
+
19
+ super(code, description, status: status)
18
20
  end
19
21
 
20
22
  def signed_in_again?
@@ -25,10 +27,10 @@ module Masks
25
27
  class Unavailable < Error
26
28
  attr_reader :secret
27
29
 
28
- def initialize(message, secret: nil)
29
- super(message)
30
-
30
+ def initialize(description, secret: nil)
31
31
  @secret = secret
32
+
33
+ super("temporarily_unavailable", description, status: 503)
32
34
  end
33
35
  end
34
36
 
@@ -63,14 +65,14 @@ module Masks
63
65
  raise Refused.new(params["error"], params["error_description"]) if params["error"].to_s != ""
64
66
 
65
67
  unless params["state"].to_s != "" && OpenSSL.secure_compare(params["state"].to_s, started["state"].to_s)
66
- raise Refused.new("invalid_state", "the state did not match the one this connection started with")
68
+ raise Refused.of("state_mismatch")
67
69
  end
68
70
 
69
71
  tokens = answered { session.complete(code: params["code"].to_s, verifier: started["verifier"].to_s) }
70
72
  held = tokens.delegations.find { |one| one["provider"] == started["provider"] }
71
73
 
72
- raise Refused.new("access_denied", "masks connected nothing for #{started['provider']}") if held.nil?
73
- raise Refused.new("invalid_grant", "masks issued no refresh token to keep the connection with") if tokens.refresh_token.to_s == ""
74
+ raise Refused.of("nothing_connected", started["provider"]) if held.nil?
75
+ raise Refused.of("no_refresh_for_connection") if tokens.refresh_token.to_s == ""
74
76
 
75
77
  Held.new(
76
78
  connection: held["connection"], provider: held["provider"], provider_name: held["provider_name"],
@@ -99,11 +101,11 @@ module Masks
99
101
  yield
100
102
  rescue Unregistered
101
103
  raise
102
- rescue Rejected => e
104
+ rescue Masks::Client::Refused => e
103
105
  raise Unavailable.new("masks answered #{e.status}: #{e.message}", secret: secret) if unavailable?(e)
104
106
 
105
- raise Refused.new(e.code, e.description, secret: secret)
106
- rescue Unreachable => e
107
+ raise Refused.new(e.code, e.description, status: e.status, secret: secret)
108
+ rescue Masks::Client::Unavailable => e
107
109
  raise Unavailable.new(e.message, secret: secret)
108
110
  end
109
111
 
@@ -12,7 +12,7 @@ module Masks
12
12
  missing = REQUIRED.find { |field| body[field].to_s.empty? }
13
13
 
14
14
  if missing
15
- raise Rejected.new(
15
+ raise Refused.new(
16
16
  body["error"] || "invalid_device_authorization_response",
17
17
  body["error_description"] || "the device authorization endpoint answered without a #{missing}"
18
18
  )
@@ -39,14 +39,14 @@ module Masks
39
39
 
40
40
  unless worker.join(HTTP::READ_TIMEOUT)
41
41
  worker.kill
42
- raise Unreachable, "#{uri.host} did not answer within #{HTTP::READ_TIMEOUT} seconds"
42
+ raise Unavailable, Masks::Client.saying("unreachable", uri.host)
43
43
  end
44
44
 
45
45
  worker.value
46
46
  rescue Error
47
47
  raise
48
48
  rescue StandardError => e
49
- raise Unreachable, "#{uri.host} failed inside this process (#{e.class})"
49
+ raise Unavailable, Masks::Client.saying("unreachable", uri.host)
50
50
  end
51
51
 
52
52
  def collected(status, headers, body, uri, limit)
@@ -54,7 +54,7 @@ module Masks
54
54
 
55
55
  body.each do |chunk|
56
56
  held << chunk.to_s.b
57
- raise Error, "#{uri.host} answered with more than #{limit} bytes" if limit && held.bytesize > limit
57
+ raise Refused.new("server_error", "#{uri.host} answered with more than #{limit} bytes") if limit && held.bytesize > limit
58
58
  end
59
59
 
60
60
  [ status.to_i, headers, held ]
@@ -5,7 +5,7 @@ module Masks
5
5
 
6
6
  def self.issued(body)
7
7
  if body["initial_access_token"].to_s.empty?
8
- raise Rejected.new(
8
+ raise Refused.new(
9
9
  body["error"] || "invalid_enrollment_response",
10
10
  body["error_description"] || "the enrollment endpoint answered without an initial_access_token"
11
11
  )
@@ -1,28 +1,39 @@
1
1
  module Masks
2
2
  module Client
3
- class Error < StandardError; end
3
+ def self.failure(key, *values)
4
+ code, template = FAILURES.fetch(key.to_sym)
4
5
 
5
- class Unreachable < Error; end
6
-
7
- class Foreign < Error; end
8
-
9
- class Insecure < Error; end
6
+ [ code, format(template, *values) ]
7
+ end
10
8
 
11
- class ReplayUnavailable < Error; end
9
+ def self.saying(key, *values)
10
+ failure(key, *values).last
11
+ end
12
12
 
13
- class Rejected < Error
14
- attr_reader :code, :description, :status
13
+ def self.worded(code, description)
14
+ [ "masks", code, description ].reject { |part| part.to_s.empty? }.join(": ")
15
+ end
15
16
 
16
- def initialize(code, description, status: nil)
17
- super([ code, description ].compact.join(": "))
17
+ class Error < StandardError
18
+ attr_reader :code, :description, :status, :uri
18
19
 
19
- @code = code
20
+ def initialize(code, description = nil, status: nil, uri: nil)
21
+ @code = code.to_s
20
22
  @description = description
21
23
  @status = status
24
+ @uri = uri
25
+
26
+ super(Masks::Client.worded(@code, description))
27
+ end
28
+ end
29
+
30
+ class Refused < Error
31
+ def self.of(key, *values)
32
+ new(*Masks::Client.failure(key, *values))
22
33
  end
23
34
  end
24
35
 
25
- class Unregistered < Rejected
36
+ class Unregistered < Refused
26
37
  CODE = "invalid_client".freeze
27
38
 
28
39
  def self.raised_by?(body)
@@ -30,19 +41,22 @@ module Masks
30
41
  end
31
42
  end
32
43
 
33
- class InvalidToken < Error; end
44
+ class InvalidToken < Error
45
+ def self.of(key, *values)
46
+ code, description = Masks::Client.failure(key, *values)
34
47
 
35
- class Challenge < Error
36
- attr_reader :code, :description, :status, :scope
48
+ new(description, code: code, missing: key.to_s == "no_token")
49
+ end
37
50
 
38
- def initialize(code, description, status:, scope: nil, dpop: false)
39
- super([ code, description ].compact.join(": "))
51
+ def initialize(description, code: "invalid_token", missing: false, dpop: nil)
52
+ @missing = missing
53
+ @dpop = dpop.nil? ? code == "invalid_dpop_proof" : dpop
40
54
 
41
- @code = code
42
- @description = description
43
- @status = status
44
- @scope = scope
45
- @dpop = dpop
55
+ super(code, description, status: 401)
56
+ end
57
+
58
+ def missing?
59
+ @missing
46
60
  end
47
61
 
48
62
  def dpop?
@@ -50,21 +64,47 @@ module Masks
50
64
  end
51
65
  end
52
66
 
53
- class Unauthenticated < Challenge
54
- def initialize(description = "a bearer token is required", code: nil)
55
- super(code, description, status: 401)
67
+ class Forbidden < Error
68
+ attr_reader :scope
69
+
70
+ def self.of(key, *values)
71
+ new(*Masks::Client.failure(key, *values))
72
+ end
73
+
74
+ def initialize(code, description, scope: nil)
75
+ @scope = scope
76
+
77
+ super(code, description, status: 403)
78
+ end
79
+ end
80
+
81
+ class Unavailable < Error
82
+ def self.of(key, *values)
83
+ new(Masks::Client.saying(key, *values))
84
+ end
85
+
86
+ def initialize(description)
87
+ super("temporarily_unavailable", description, status: 503)
56
88
  end
57
89
  end
58
90
 
59
- class Unauthorized < Challenge
60
- def initialize(description, code: "invalid_token", dpop: false)
61
- super(code, description, status: 401, dpop: dpop)
91
+ class Insecure < Error
92
+ def self.of(key, *values)
93
+ new(Masks::Client.saying(key, *values))
94
+ end
95
+
96
+ def initialize(description)
97
+ super("insecure_url", description)
62
98
  end
63
99
  end
64
100
 
65
- class Forbidden < Challenge
66
- def initialize(code, description, scope: nil)
67
- super(code, description, status: 403, scope: scope)
101
+ class Foreign < Error
102
+ def self.of(key, *values)
103
+ new(Masks::Client.saying(key, *values))
104
+ end
105
+
106
+ def initialize(description)
107
+ super("foreign_origin", description)
68
108
  end
69
109
  end
70
110
  end
@@ -0,0 +1,81 @@
1
+ module Masks
2
+ module Client
3
+ FAILURES = {
4
+ no_token: [ "invalid_token", "the request carries no token" ],
5
+ not_a_jwt: [ "invalid_token", "that token is not a signed JWT" ],
6
+ no_key: [ "invalid_token", "%s publishes no key %s" ],
7
+ not_signed_by: [ "invalid_token", "that token was not signed by %s" ],
8
+ not_an_algorithm: [ "invalid_token", "%s is not an algorithm masks signs with" ],
9
+ wrong_type: [ "invalid_token", "that token is typed %s, not %s" ],
10
+ expired: [ "invalid_token", "that token has expired" ],
11
+ not_valid_yet: [ "invalid_token", "that token is not valid yet" ],
12
+ issued_in_future: [ "invalid_token", "that token was issued in the future" ],
13
+ other_issuer: [ "invalid_token", "that token was issued by another server" ],
14
+ other_audience: [ "invalid_token", "that token was issued for another app" ],
15
+ missing_claims: [ "invalid_token", "that token is missing %s" ],
16
+ unverified: [ "invalid_token", "that token could not be verified" ],
17
+ too_old: [ "invalid_token", "the person signed in longer ago than max_age allows" ],
18
+ bound_as_bearer: [ "invalid_token", "that token is held to a key, so it is presented as DPoP with a proof" ],
19
+ unbound_as_dpop: [ "invalid_token", "that token is held to no key, so it is presented as Bearer" ],
20
+ not_active: [ "invalid_token", "the issuer reports this token is not active" ],
21
+ no_id_token: [ "invalid_token", "the token endpoint answered without an ID token" ],
22
+ id_token_other_sign_in: [ "invalid_token", "that ID token was issued for another sign-in" ],
23
+ id_token_other_client: [ "invalid_token", "that ID token was issued to another client" ],
24
+ id_token_several_audiences: [ "invalid_token", "that ID token names several audiences and was not issued to this client" ],
25
+ id_token_other_access_token: [ "invalid_token", "that ID token was issued with another access token" ],
26
+ id_token_other_organization: [ "invalid_token", "that ID token does not name the organization %s" ],
27
+ not_a_logout: [ "invalid_token", "that token is not about a logout" ],
28
+ logout_names_nobody: [ "invalid_token", "that logout token names neither a subject nor a session" ],
29
+ logout_with_nonce: [ "invalid_token", "that logout token carries a nonce, so it is an ID token" ],
30
+ one_proof: [ "invalid_dpop_proof", "exactly one DPoP proof is required" ],
31
+ proof_not_a_jwt: [ "invalid_dpop_proof", "that proof is not a signed JWT" ],
32
+ proof_type: [ "invalid_dpop_proof", "a proof is typed %s" ],
33
+ proof_algorithm: [ "invalid_dpop_proof", "%s is not an algorithm a proof may use" ],
34
+ proof_no_key: [ "invalid_dpop_proof", "a proof carries the public key that signed it" ],
35
+ proof_key_private: [ "invalid_dpop_proof", "a proof key is public" ],
36
+ proof_key_unreadable: [ "invalid_dpop_proof", "that proof carries a key this API cannot read" ],
37
+ proof_unsigned: [ "invalid_dpop_proof", "that proof was not signed by the key it carries" ],
38
+ proof_other_key: [ "invalid_dpop_proof", "that proof was signed by another key than the token is held to" ],
39
+ proof_other_token: [ "invalid_dpop_proof", "that proof was made for another token" ],
40
+ proof_other_method: [ "invalid_dpop_proof", "that proof was made for another method" ],
41
+ proof_other_url: [ "invalid_dpop_proof", "that proof was made for another URL" ],
42
+ proof_not_now: [ "invalid_dpop_proof", "that proof was not made just now" ],
43
+ proof_no_jti: [ "invalid_dpop_proof", "a proof carries a jti" ],
44
+ proof_replayed: [ "invalid_dpop_proof", "that proof has already been used" ],
45
+ no_scope: [ "insufficient_scope", "this token does not carry %s" ],
46
+ not_granted: [ "insufficient_scope", "masks did not grant %s" ],
47
+ no_privilege: [ "insufficient_scope", "this account does not hold the %s privilege" ],
48
+ no_organization: [ "insufficient_organization", "this sign-in names no organization" ],
49
+ other_organization: [ "insufficient_organization", "this sign-in is for %s, and this needs %s" ],
50
+ other_role: [ "insufficient_role", "%s in %s is not %s" ],
51
+ unreachable: [ "temporarily_unavailable", "%s could not be reached" ],
52
+ keys_unreachable: [ "temporarily_unavailable", "%s could not be reached for its keys" ],
53
+ replay_store_unreachable: [ "temporarily_unavailable", "the replay store could not be reached" ],
54
+ replay_store_answer: [ "temporarily_unavailable", "the replay store answered with something other than true or false" ],
55
+ store_unreachable: [ "temporarily_unavailable", "the store could not be reached" ],
56
+ not_checked: [ "temporarily_unavailable", "the token could not be checked" ],
57
+ plain_http: [ "insecure_url", "plain http is allowed only on this machine: %s" ],
58
+ not_http: [ "insecure_url", "%s is not an http or https URL" ],
59
+ other_origin: [ "foreign_origin", "that URL belongs to another origin: %s" ],
60
+ tokens_only_for: [ "foreign_origin", "tokens are held only for %s, not %s" ],
61
+ answered_status: [ "server_error", "%s answered %s" ],
62
+ not_json: [ "server_error", "%s answered with something other than JSON" ],
63
+ not_an_object: [ "server_error", "%s answered with JSON that is not an object" ],
64
+ too_large: [ "server_error", "%s answered with more than a megabyte" ],
65
+ publishes_nothing: [ "invalid_issuer", "%s publishes no %s" ],
66
+ names_another: [ "invalid_issuer", "%s publishes a document naming %s" ],
67
+ resource_names_another: [ "invalid_target", "%s publishes metadata naming %s" ],
68
+ state_mismatch: [ "invalid_state", "the state did not match the one this sign-in started with" ],
69
+ nothing_in_flight: [ "invalid_request", "this browser has no sign-in in flight" ],
70
+ no_callback_code: [ "invalid_request", "the callback carried no code" ],
71
+ code_expired: [ "expired_token", "the code expired before anyone approved it" ],
72
+ no_refresh_token: [ "invalid_grant", "there is no refresh token to renew these tokens with" ],
73
+ not_held_to_key: [ "invalid_dpop_proof", "the token endpoint answered with a token that is not held to this key" ],
74
+ no_avatars: [ "invalid_style", "%s does not serve %s avatars" ],
75
+ not_connected: [ "handshake_required", "this app has not shaken hands with %s" ],
76
+ nothing_connected: [ "access_denied", "masks connected nothing for %s" ],
77
+ no_refresh_for_connection: [ "invalid_grant", "masks issued no refresh token to keep the connection with" ],
78
+ delegation_not_answering: [ "temporarily_unavailable", "masks is not answering" ]
79
+ }.freeze
80
+ end
81
+ end
@@ -90,7 +90,7 @@ module Masks
90
90
  return if present?(held) && present?(returned) &&
91
91
  OpenSSL.secure_compare(returned.to_s, held.to_s)
92
92
 
93
- refuse("invalid_state", "the handshake did not match this browser")
93
+ refuse(*Masks::Client.failure("state_mismatch"))
94
94
  end
95
95
 
96
96
  def verify_issuer!(named)
@@ -101,7 +101,7 @@ module Masks
101
101
  end
102
102
 
103
103
  def refuse(code, description)
104
- raise Rejected.new(code.to_s, description.to_s)
104
+ raise Refused.new(code.to_s, description.to_s)
105
105
  end
106
106
 
107
107
  def normalize(params)
@@ -77,7 +77,7 @@ module Masks
77
77
  read_timeout: READ_TIMEOUT
78
78
  ) { |http| limit ? limited(http, request, limit) : http.request(request) }
79
79
  rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
80
- raise Unreachable, "#{uri.host} is unreachable (#{e.class})"
80
+ raise Unavailable, Masks::Client.saying("unreachable", uri.host)
81
81
  end
82
82
 
83
83
  def checked(uri)
@@ -85,11 +85,10 @@ module Masks
85
85
  return uri if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && (Masks::Client.plain_http? || loopback?(uri.hostname))
86
86
 
87
87
  if uri.is_a?(URI::HTTP)
88
- raise Insecure, "#{Nonces.origin(uri)} is plain http, which masks allows only for loopback hosts " \
89
- "unless Masks::Client.plain_http is set"
88
+ raise Insecure, Masks::Client.saying("plain_http", Nonces.origin(uri))
90
89
  end
91
90
 
92
- raise Error, "#{uri} is not an http or https URL"
91
+ raise Insecure.of("not_http", uri)
93
92
  end
94
93
 
95
94
  def loopback?(host)
@@ -108,7 +107,7 @@ module Masks
108
107
 
109
108
  response.read_body do |chunk|
110
109
  body << chunk
111
- raise Error, "#{request.uri.host} answered with more than #{limit} bytes" if body.bytesize > limit
110
+ raise Refused.new("server_error", "#{request.uri.host} answered with more than #{limit} bytes") if body.bytesize > limit
112
111
  end
113
112
 
114
113
  response.body = body
@@ -119,13 +118,10 @@ module Masks
119
118
  body = decoded(response)
120
119
 
121
120
  unless response.is_a?(Net::HTTPSuccess)
122
- refusal = Unregistered.raised_by?(body) ? Unregistered : Rejected
121
+ refusal = Unregistered.raised_by?(body) ? Unregistered : Refused
122
+ code, description = refused_with(body, response)
123
123
 
124
- raise refusal.new(
125
- body["error"] || "http_#{response.code}",
126
- body["error_description"] || response.message,
127
- status: response.code.to_i
128
- )
124
+ raise refusal.new(code, description, status: response.code.to_i)
129
125
  end
130
126
 
131
127
  body
@@ -140,13 +136,17 @@ module Masks
140
136
  return held if held.is_a?(Hash)
141
137
  return {} unless response.is_a?(Net::HTTPSuccess)
142
138
 
143
- raise Rejected.new("invalid_response", "#{origin(response)} answered with JSON that is not an object",
144
- status: response.code.to_i)
139
+ raise Refused.new(*Masks::Client.failure("not_an_object", origin(response)), status: response.code.to_i)
145
140
  rescue JSON::ParserError
146
141
  return {} unless response.is_a?(Net::HTTPSuccess)
147
142
 
148
- raise Rejected.new("invalid_response", "#{origin(response)} answered with something other than JSON",
149
- status: response.code.to_i)
143
+ raise Refused.new(*Masks::Client.failure("not_json", origin(response)), status: response.code.to_i)
144
+ end
145
+
146
+ def refused_with(body, response)
147
+ return Masks::Client.failure("answered_status", origin(response), response.code.to_i) if body["error"].to_s.empty?
148
+
149
+ [ body["error"], body["error_description"] || response.message ]
150
150
  end
151
151
 
152
152
  def origin(response)
@@ -0,0 +1,35 @@
1
+ module Masks
2
+ module Client
3
+ class IdToken
4
+ REQUIRED = %w[iss sub aud exp iat].freeze
5
+
6
+ class << self
7
+ def verify(token, issuer:, client_id:, nonce: nil, access_token: nil, max_age: nil)
8
+ claims = Verifier.new(issuer, audience: client_id).verify(token, required: REQUIRED, typ: Verifier::ID_TOKEN)
9
+
10
+ raise InvalidToken.of("id_token_several_audiences") if Array(claims["aud"]).size > 1 && !claims.key?("azp")
11
+ raise InvalidToken.of("id_token_other_client") if claims.key?("azp") && claims["azp"] != client_id
12
+ raise InvalidToken.of("id_token_other_sign_in") if nonce && !same?(claims["nonce"], nonce)
13
+
14
+ if access_token && claims.key?("at_hash") && !same?(claims["at_hash"], half_hash(access_token))
15
+ raise InvalidToken.of("id_token_other_access_token")
16
+ end
17
+
18
+ Verifier.recent!(claims, max_age)
19
+ end
20
+
21
+ def half_hash(value)
22
+ digest = Digest::SHA256.digest(value.to_s)
23
+
24
+ Base64.urlsafe_encode64(digest[0, digest.bytesize / 2], padding: false)
25
+ end
26
+
27
+ private
28
+
29
+ def same?(held, wanted)
30
+ held.is_a?(String) && !held.empty? && OpenSSL.secure_compare(held, wanted.to_s)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -19,14 +19,14 @@ module Masks
19
19
 
20
20
  def permit!(scope)
21
21
  unless active?
22
- raise Unauthorized.new("the issuer reports this token is not active")
22
+ raise InvalidToken.new(Masks::Client.saying("not_active"))
23
23
  end
24
24
 
25
25
  super
26
26
  end
27
27
 
28
28
  def member!(*roles, organization: nil)
29
- raise Unauthorized.new("the issuer reports this token is not active") unless active?
29
+ raise InvalidToken.new(Masks::Client.saying("not_active")) unless active?
30
30
 
31
31
  super
32
32
  end