masks 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/app/controllers/masks/rails/logouts_controller.rb +3 -3
- data/app/controllers/masks/rails/sessions_controller.rb +4 -26
- data/lib/masks/client/claims.rb +4 -9
- data/lib/masks/client/delegations.rb +16 -14
- data/lib/masks/client/device.rb +1 -1
- data/lib/masks/client/embedded.rb +3 -3
- data/lib/masks/client/enrollment.rb +1 -1
- data/lib/masks/client/errors.rb +72 -32
- data/lib/masks/client/failures.rb +81 -0
- data/lib/masks/client/handshake.rb +2 -2
- data/lib/masks/client/http.rb +15 -15
- data/lib/masks/client/id_token.rb +35 -0
- data/lib/masks/client/introspection.rb +2 -2
- data/lib/masks/client/issuer.rb +4 -7
- data/lib/masks/client/logout.rb +6 -12
- data/lib/masks/client/proof.rb +23 -22
- data/lib/masks/client/rack.rb +3 -3
- data/lib/masks/client/registration.rb +1 -1
- data/lib/masks/client/resource.rb +12 -13
- data/lib/masks/client/session.rb +16 -10
- data/lib/masks/client/tokens.rb +1 -1
- data/lib/masks/client/transport.rb +1 -1
- data/lib/masks/client/verifier.rb +53 -17
- data/lib/masks/client.rb +3 -0
- data/lib/masks/rails/authentication.rb +7 -7
- data/lib/masks/rails/cache.rb +6 -8
- data/lib/masks/rails/configuration.rb +5 -1
- data/lib/masks/rails/protected_resource.rb +8 -6
- data/lib/masks/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 24592fe5fb130abf36cb52a58a03c8184d55c428045f1698d3c061143ccffe06
|
|
4
|
+
data.tar.gz: b5bea82b253704173650c494018a16e1d5eb105fd0c3511c7d8c46141f1fe3cc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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?
|
data/lib/masks/client/claims.rb
CHANGED
|
@@ -160,17 +160,16 @@ module Masks
|
|
|
160
160
|
held = self.organization
|
|
161
161
|
|
|
162
162
|
unless held.present?
|
|
163
|
-
raise Forbidden.new("
|
|
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("
|
|
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("
|
|
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 :
|
|
10
|
+
attr_reader :secret
|
|
11
11
|
|
|
12
|
-
def
|
|
13
|
-
|
|
12
|
+
def self.of(key, *values)
|
|
13
|
+
new(*Masks::Client.failure(key, *values))
|
|
14
|
+
end
|
|
14
15
|
|
|
15
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
73
|
-
raise Refused.
|
|
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
|
|
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
|
|
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
|
|
data/lib/masks/client/device.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
)
|
data/lib/masks/client/errors.rb
CHANGED
|
@@ -1,28 +1,39 @@
|
|
|
1
1
|
module Masks
|
|
2
2
|
module Client
|
|
3
|
-
|
|
3
|
+
def self.failure(key, *values)
|
|
4
|
+
code, template = FAILURES.fetch(key.to_sym)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class Foreign < Error; end
|
|
8
|
-
|
|
9
|
-
class Insecure < Error; end
|
|
6
|
+
[ code, format(template, *values) ]
|
|
7
|
+
end
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
def self.saying(key, *values)
|
|
10
|
+
failure(key, *values).last
|
|
11
|
+
end
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
def self.worded(code, description)
|
|
14
|
+
[ "masks", code, description ].reject { |part| part.to_s.empty? }.join(": ")
|
|
15
|
+
end
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
class Error < StandardError
|
|
18
|
+
attr_reader :code, :description, :status, :uri
|
|
18
19
|
|
|
19
|
-
|
|
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 <
|
|
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
|
|
44
|
+
class InvalidToken < Error
|
|
45
|
+
def self.of(key, *values)
|
|
46
|
+
code, description = Masks::Client.failure(key, *values)
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
new(description, code: code, missing: key.to_s == "no_token")
|
|
49
|
+
end
|
|
37
50
|
|
|
38
|
-
def initialize(
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
@
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
60
|
-
def
|
|
61
|
-
|
|
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
|
|
66
|
-
def
|
|
67
|
-
|
|
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("
|
|
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
|
|
104
|
+
raise Refused.new(code.to_s, description.to_s)
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
def normalize(params)
|
data/lib/masks/client/http.rb
CHANGED
|
@@ -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
|
|
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, "
|
|
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
|
|
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
|
|
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 :
|
|
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
|
|
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
|
|
149
|
-
|
|
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
|
|
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
|
|
29
|
+
raise InvalidToken.new(Masks::Client.saying("not_active")) unless active?
|
|
30
30
|
|
|
31
31
|
super
|
|
32
32
|
end
|