masks 0.10.0 → 0.11.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 +30 -0
- data/README.md +128 -8
- data/app/controllers/masks/rails/handshakes_controller.rb +8 -0
- data/app/controllers/masks/rails/sessions_controller.rb +2 -1
- data/lib/masks/client/device.rb +47 -0
- data/lib/masks/client/embedded.rb +125 -0
- data/lib/masks/client/enrollment.rb +33 -0
- data/lib/masks/client/errors.rb +6 -0
- data/lib/masks/client/http.rb +89 -15
- data/lib/masks/client/issuer.rb +2 -1
- data/lib/masks/client/key.rb +100 -0
- data/lib/masks/client/nonces.rb +31 -0
- data/lib/masks/client/proof.rb +7 -1
- data/lib/masks/client/rack.rb +8 -0
- data/lib/masks/client/registration.rb +32 -15
- data/lib/masks/client/session.rb +120 -11
- data/lib/masks/client/tokens.rb +4 -0
- data/lib/masks/client/transport.rb +114 -0
- data/lib/masks/client.rb +21 -0
- data/lib/masks/rails/authentication.rb +23 -8
- data/lib/masks/rails/configuration.rb +7 -2
- data/lib/masks/rails/protected_resource.rb +13 -0
- data/lib/masks/version.rb +1 -1
- metadata +7 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Key
|
|
4
|
+
CURVE = "prime256v1".freeze
|
|
5
|
+
ALGORITHM = "ES256".freeze
|
|
6
|
+
PUBLIC = %w[kty crv x y].freeze
|
|
7
|
+
|
|
8
|
+
def self.generate
|
|
9
|
+
new(OpenSSL::PKey::EC.generate(CURVE))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.parse(data)
|
|
13
|
+
held = data.is_a?(String) && !data.lstrip.start_with?("{") ? OpenSSL::PKey.read(data) : imported(data)
|
|
14
|
+
|
|
15
|
+
new(held)
|
|
16
|
+
rescue OpenSSL::PKey::PKeyError, JSON::ParserError, JWT::JWKError, ArgumentError
|
|
17
|
+
raise ArgumentError, "a key is a private P-256 key, as a PEM or a JWK"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.from(value)
|
|
21
|
+
case value
|
|
22
|
+
when self then value
|
|
23
|
+
when OpenSSL::PKey::PKey then new(value)
|
|
24
|
+
else parse(value)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.imported(data)
|
|
29
|
+
held = data.is_a?(String) ? JSON.parse(data) : data.to_h
|
|
30
|
+
|
|
31
|
+
JWT::JWK.import(held.transform_keys(&:to_sym)).signing_key
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.htu(url)
|
|
35
|
+
uri = URI.parse(url.to_s)
|
|
36
|
+
uri.query = nil
|
|
37
|
+
uri.fragment = nil
|
|
38
|
+
|
|
39
|
+
uri.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attr_reader :private_key, :thumbprint
|
|
43
|
+
|
|
44
|
+
def initialize(private_key)
|
|
45
|
+
unless private_key.is_a?(OpenSSL::PKey::EC) && private_key.private? && private_key.group.curve_name == CURVE
|
|
46
|
+
raise ArgumentError, "a key is a private P-256 key"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@private_key = private_key
|
|
50
|
+
@public = JWT::JWK.new(private_key).export.transform_keys(&:to_s).slice(*PUBLIC).freeze
|
|
51
|
+
@thumbprint = Proof.thumbprint(@public)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
alias_method :kid, :thumbprint
|
|
55
|
+
|
|
56
|
+
def public_jwk
|
|
57
|
+
@public.merge("kid" => thumbprint, "alg" => ALGORITHM, "use" => "sig")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def jwks
|
|
61
|
+
{ "keys" => [ public_jwk ] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def to_h
|
|
65
|
+
JWT::JWK.new(private_key).export(include_private: true).transform_keys(&:to_s)
|
|
66
|
+
.merge("kid" => thumbprint, "alg" => ALGORITHM, "use" => "sig")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_json(*)
|
|
70
|
+
JSON.generate(to_h)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_pem
|
|
74
|
+
private_key.private_to_pem
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inspect
|
|
78
|
+
"#<#{self.class.name} #{thumbprint}>"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def sign(claims, header = {})
|
|
82
|
+
JWT.encode(claims, private_key, ALGORITHM, header)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def proof(method, url, access_token: nil, nonce: nil)
|
|
86
|
+
claims = {
|
|
87
|
+
"jti" => SecureRandom.urlsafe_base64(24),
|
|
88
|
+
"htm" => method.to_s.upcase,
|
|
89
|
+
"htu" => self.class.htu(url),
|
|
90
|
+
"iat" => Time.now.to_i
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
claims["ath"] = Proof.digest(access_token) unless access_token.to_s.empty?
|
|
94
|
+
claims["nonce"] = nonce unless nonce.to_s.empty?
|
|
95
|
+
|
|
96
|
+
sign(claims, { typ: Proof::TYPE, jwk: @public })
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Nonces
|
|
4
|
+
HEADER = "DPoP-Nonce".freeze
|
|
5
|
+
|
|
6
|
+
def self.origin(url)
|
|
7
|
+
uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
|
|
8
|
+
port = ":#{uri.port}" unless uri.port.nil? || uri.port == uri.default_port
|
|
9
|
+
|
|
10
|
+
"#{uri.scheme.to_s.downcase}://#{uri.host.to_s.downcase}#{port}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@held = {}
|
|
15
|
+
@lock = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def [](url)
|
|
19
|
+
@lock.synchronize { @held[self.class.origin(url)] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def learn(url, response)
|
|
23
|
+
wanted = response[HEADER].to_s.strip
|
|
24
|
+
|
|
25
|
+
return nil if wanted.empty?
|
|
26
|
+
|
|
27
|
+
@lock.synchronize { @held[self.class.origin(url)] = wanted }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/masks/client/proof.rb
CHANGED
|
@@ -139,11 +139,17 @@ module Masks
|
|
|
139
139
|
jti = claims["jti"].to_s
|
|
140
140
|
|
|
141
141
|
raise Invalid, "a proof must carry a jti" if jti.empty?
|
|
142
|
-
return if @replay.nil? ||
|
|
142
|
+
return if @replay.nil? || first?("#{thumbprint}:#{jti}")
|
|
143
143
|
|
|
144
144
|
raise Invalid, "that proof has already been used"
|
|
145
145
|
end
|
|
146
146
|
|
|
147
|
+
def first?(key)
|
|
148
|
+
@replay.first?(key, expires_in: MEMORY)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
raise ReplayUnavailable, "the replay store could not say whether that proof is new (#{e.class})"
|
|
151
|
+
end
|
|
152
|
+
|
|
147
153
|
def same?(held, expected)
|
|
148
154
|
!held.to_s.empty? && held.bytesize == expected.to_s.bytesize &&
|
|
149
155
|
OpenSSL.fixed_length_secure_compare(held, expected.to_s)
|
data/lib/masks/client/rack.rb
CHANGED
|
@@ -25,6 +25,8 @@ module Masks
|
|
|
25
25
|
return passthrough(env, e) if @optional && e.is_a?(Unauthenticated)
|
|
26
26
|
|
|
27
27
|
refuse(resource(env), e)
|
|
28
|
+
rescue ReplayUnavailable
|
|
29
|
+
unavailable
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
private
|
|
@@ -56,6 +58,12 @@ module Masks
|
|
|
56
58
|
@resource.respond_to?(:call) ? @resource.call(env) : @resource
|
|
57
59
|
end
|
|
58
60
|
|
|
61
|
+
def unavailable
|
|
62
|
+
body = JSON.generate({ "error" => "temporarily_unavailable", "error_description" => "the token could not be checked" })
|
|
63
|
+
|
|
64
|
+
[ 503, { "content-type" => "application/json", "cache-control" => "no-store" }, [ body ] ]
|
|
65
|
+
end
|
|
66
|
+
|
|
59
67
|
def refuse(resource, error)
|
|
60
68
|
body = JSON.generate(
|
|
61
69
|
{ "error" => error.code, "error_description" => error.description }.compact
|
|
@@ -6,31 +6,41 @@ module Masks
|
|
|
6
6
|
def self.create(issuer, token: nil, **attributes)
|
|
7
7
|
issuer = Issuer.resolve(issuer)
|
|
8
8
|
headers = token ? { "Authorization" => "Bearer #{token}" } : {}
|
|
9
|
-
body = HTTP.post_json(
|
|
10
|
-
issuer.endpoint("registration_endpoint"), stringify(attributes), headers
|
|
11
|
-
)
|
|
9
|
+
body = HTTP.post_json(endpoint(issuer, token), stringify(attributes), headers)
|
|
12
10
|
|
|
13
11
|
new(issuer, body)
|
|
14
12
|
end
|
|
15
13
|
|
|
14
|
+
def self.endpoint(issuer, token)
|
|
15
|
+
return issuer.endpoint("registration_endpoint") if token.nil? || issuer.discovery["registration_endpoint"]
|
|
16
|
+
|
|
17
|
+
"#{issuer.url}/register"
|
|
18
|
+
end
|
|
19
|
+
|
|
16
20
|
def self.stringify(attributes)
|
|
17
21
|
{
|
|
18
22
|
"client_name" => attributes[:name] || attributes[:client_name],
|
|
19
23
|
"redirect_uris" => Array(attributes[:redirect_uris]),
|
|
20
|
-
"grant_types" =>
|
|
21
|
-
"response_types" =>
|
|
24
|
+
"grant_types" => Array(attributes[:grant_types]),
|
|
25
|
+
"response_types" => Array(attributes[:response_types]),
|
|
22
26
|
"scope" => Array(attributes[:scope]).join(" "),
|
|
23
27
|
"token_endpoint_auth_method" => attributes[:token_endpoint_auth_method],
|
|
24
28
|
"application_type" => attributes[:application_type],
|
|
25
29
|
"client_uri" => attributes[:client_uri],
|
|
26
30
|
"logo_uri" => attributes[:logo_uri],
|
|
27
|
-
"backchannel_logout_uri" => attributes[:backchannel_logout_uri]
|
|
31
|
+
"backchannel_logout_uri" => attributes[:backchannel_logout_uri],
|
|
32
|
+
"jwks" => keyset(attributes[:jwks]),
|
|
33
|
+
"dpop_bound_access_tokens" => attributes[:dpop_bound_access_tokens]
|
|
28
34
|
}.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
|
|
29
35
|
end
|
|
30
36
|
|
|
31
|
-
def self.
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
def self.keyset(value)
|
|
38
|
+
case value
|
|
39
|
+
when nil then nil
|
|
40
|
+
when Key then value.jwks
|
|
41
|
+
when Array then { "keys" => value.map { |key| key.is_a?(Key) ? key.public_jwk : key } }
|
|
42
|
+
else value
|
|
43
|
+
end
|
|
34
44
|
end
|
|
35
45
|
|
|
36
46
|
def self.held(issuer, credentials)
|
|
@@ -62,18 +72,18 @@ module Masks
|
|
|
62
72
|
GONE = (REFUSED + [ 404 ]).freeze
|
|
63
73
|
|
|
64
74
|
def read
|
|
65
|
-
still_ours(REFUSED) { HTTP.get(
|
|
75
|
+
still_ours(REFUSED) { HTTP.get(owned, authorization) }
|
|
66
76
|
end
|
|
67
77
|
|
|
68
78
|
def update(**attributes)
|
|
69
79
|
@metadata = metadata.merge(
|
|
70
|
-
still_ours(REFUSED) { HTTP.put_json(
|
|
80
|
+
still_ours(REFUSED) { HTTP.put_json(owned, self.class.stringify(attributes), authorization) }
|
|
71
81
|
)
|
|
72
82
|
self
|
|
73
83
|
end
|
|
74
84
|
|
|
75
85
|
def delete
|
|
76
|
-
still_ours(GONE) { HTTP.delete(
|
|
86
|
+
still_ours(GONE) { HTTP.delete(owned, authorization) }
|
|
77
87
|
true
|
|
78
88
|
end
|
|
79
89
|
|
|
@@ -88,18 +98,25 @@ module Masks
|
|
|
88
98
|
{ "Authorization" => "Bearer #{access_token}" }
|
|
89
99
|
end
|
|
90
100
|
|
|
91
|
-
def session(redirect_uri
|
|
101
|
+
def session(redirect_uri: nil, scope: Session::DEFAULT_SCOPE, **options)
|
|
92
102
|
Session.new(
|
|
93
103
|
issuer: issuer,
|
|
94
104
|
client_id: client_id,
|
|
95
|
-
client_secret: client_secret,
|
|
105
|
+
client_secret: (client_secret unless options[:private_key]),
|
|
96
106
|
redirect_uri: redirect_uri,
|
|
97
|
-
scope: scope
|
|
107
|
+
scope: scope,
|
|
108
|
+
**options
|
|
98
109
|
)
|
|
99
110
|
end
|
|
100
111
|
|
|
101
112
|
private
|
|
102
113
|
|
|
114
|
+
def owned
|
|
115
|
+
return uri if Nonces.origin(uri) == Nonces.origin(issuer.url)
|
|
116
|
+
|
|
117
|
+
raise Foreign, "the registration names #{Nonces.origin(uri)}, which is not #{issuer.url}"
|
|
118
|
+
end
|
|
119
|
+
|
|
103
120
|
def still_ours(statuses)
|
|
104
121
|
yield
|
|
105
122
|
rescue Unregistered
|
data/lib/masks/client/session.rb
CHANGED
|
@@ -13,17 +13,21 @@ module Masks
|
|
|
13
13
|
ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".freeze
|
|
14
14
|
ASSERTION_LIFETIME = 60
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
NO_NONCE = "use_dpop_nonce".freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scope, :private_key, :key_id, :dpop
|
|
17
19
|
|
|
18
20
|
def initialize(issuer:, client_id:, redirect_uri: nil, client_secret: nil, private_key: nil, key_id: nil,
|
|
19
|
-
scope: DEFAULT_SCOPE)
|
|
21
|
+
dpop: nil, scope: DEFAULT_SCOPE)
|
|
20
22
|
raise ArgumentError, "a client authenticates with a secret or a private key, not both" if client_secret && private_key
|
|
21
23
|
|
|
22
24
|
@issuer = Issuer.resolve(issuer)
|
|
23
25
|
@client_id = client_id
|
|
24
26
|
@client_secret = client_secret
|
|
25
|
-
@private_key = private_key.is_a?(
|
|
26
|
-
@
|
|
27
|
+
@private_key = private_key.is_a?(Key) ? private_key.private_key : private_key
|
|
28
|
+
@private_key = OpenSSL::PKey.read(@private_key) if @private_key.is_a?(String)
|
|
29
|
+
@key_id = key_id || (private_key.thumbprint if private_key.is_a?(Key))
|
|
30
|
+
@dpop = dpop && Key.from(dpop)
|
|
27
31
|
@redirect_uri = redirect_uri
|
|
28
32
|
@scope = Array(scope)
|
|
29
33
|
end
|
|
@@ -71,7 +75,7 @@ module Masks
|
|
|
71
75
|
|
|
72
76
|
Array(resource).each { |value| form << [ "resource", value ] }
|
|
73
77
|
|
|
74
|
-
|
|
78
|
+
token(form)
|
|
75
79
|
end
|
|
76
80
|
|
|
77
81
|
def client_credentials(scope: nil, resource: nil)
|
|
@@ -80,7 +84,23 @@ module Masks
|
|
|
80
84
|
form << [ "scope", Array(scope).join(" ") ] if scope
|
|
81
85
|
Array(resource).each { |value| form << [ "resource", value ] }
|
|
82
86
|
|
|
83
|
-
|
|
87
|
+
token(form)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def enroll(scope:, name: nil, expires_in: nil)
|
|
91
|
+
form = [ [ "client_id", client_id ], [ "scope", Array(scope).join(" ") ] ]
|
|
92
|
+
|
|
93
|
+
form << [ "client_name", name ] if name
|
|
94
|
+
form << [ "expires_in", expires_in.to_i ] if expires_in
|
|
95
|
+
|
|
96
|
+
Enrollment.issued(post("enrollment_endpoint", form))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def unenroll(machine_id)
|
|
100
|
+
url = "#{issuer.endpoint('enrollment_endpoint')}/#{URI.encode_www_form_component(machine_id)}"
|
|
101
|
+
|
|
102
|
+
HTTP.delete_form(url, [ [ "client_id", client_id ] ] + assertion, authorization)
|
|
103
|
+
true
|
|
84
104
|
end
|
|
85
105
|
|
|
86
106
|
def refresh(refresh_token, resource: nil, scope: nil)
|
|
@@ -93,7 +113,7 @@ module Masks
|
|
|
93
113
|
form << [ "scope", Array(scope).join(" ") ] if scope
|
|
94
114
|
Array(resource).each { |value| form << [ "resource", value ] }
|
|
95
115
|
|
|
96
|
-
|
|
116
|
+
token(form)
|
|
97
117
|
end
|
|
98
118
|
|
|
99
119
|
def exchange(subject_token, scope: nil, resource: nil, lifetime: nil, requested_token_type: nil, audience: nil,
|
|
@@ -114,7 +134,46 @@ module Masks
|
|
|
114
134
|
form << [ "requested_lifetime", lifetime.to_i ] if lifetime
|
|
115
135
|
Array(resource).each { |value| form << [ "resource", value ] }
|
|
116
136
|
|
|
117
|
-
|
|
137
|
+
token(form)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def device_authorization(scope: nil, resource: nil)
|
|
141
|
+
form = [ [ "client_id", client_id ] ]
|
|
142
|
+
|
|
143
|
+
form << [ "scope", Array(scope).join(" ") ] if scope
|
|
144
|
+
Array(resource).each { |value| form << [ "resource", value ] }
|
|
145
|
+
|
|
146
|
+
Device.issued(post("device_authorization_endpoint", form))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def await_device(device, pause: Device::INTERVAL)
|
|
150
|
+
form = [ [ "grant_type", Device::GRANT_TYPE ], [ "device_code", device.device_code ], [ "client_id", client_id ] ]
|
|
151
|
+
interval = device.interval.positive? ? device.interval : pause
|
|
152
|
+
|
|
153
|
+
loop do
|
|
154
|
+
raise Rejected.new("expired_token", "the code expired before anyone approved it") if device.expired?
|
|
155
|
+
|
|
156
|
+
sleep(interval)
|
|
157
|
+
|
|
158
|
+
begin
|
|
159
|
+
return token(form)
|
|
160
|
+
rescue Rejected => e
|
|
161
|
+
case e.code
|
|
162
|
+
when "authorization_pending" then next
|
|
163
|
+
when "slow_down" then interval += pause
|
|
164
|
+
else raise
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def transport(*origins, tokens: nil, scope: nil, resource: nil, &keep)
|
|
171
|
+
Transport.new(*origins, tokens: tokens, key: dpop) do |held|
|
|
172
|
+
renewed = tokens ? renew(held, scope: scope, resource: resource) : client_credentials(scope: scope, resource: resource)
|
|
173
|
+
|
|
174
|
+
keep&.call(renewed)
|
|
175
|
+
renewed
|
|
176
|
+
end
|
|
118
177
|
end
|
|
119
178
|
|
|
120
179
|
def revoke(token, hint: nil)
|
|
@@ -147,8 +206,13 @@ module Masks
|
|
|
147
206
|
)
|
|
148
207
|
end
|
|
149
208
|
|
|
150
|
-
def userinfo(
|
|
151
|
-
|
|
209
|
+
def userinfo(tokens)
|
|
210
|
+
url = issuer.endpoint("userinfo_endpoint")
|
|
211
|
+
|
|
212
|
+
return HTTP.get(url, "Authorization" => "Bearer #{tokens}") unless tokens.is_a?(Tokens)
|
|
213
|
+
return HTTP.get(url, "Authorization" => "Bearer #{tokens.access_token}") unless tokens.bound?
|
|
214
|
+
|
|
215
|
+
HTTP.parse(Transport.new(url, tokens: tokens, key: dpop).request("GET", url, limit: HTTP::READ_LIMIT))
|
|
152
216
|
end
|
|
153
217
|
|
|
154
218
|
def identity(tokens)
|
|
@@ -162,19 +226,64 @@ module Masks
|
|
|
162
226
|
def profile(tokens)
|
|
163
227
|
return {} if tokens.access_token.nil?
|
|
164
228
|
|
|
165
|
-
userinfo(tokens
|
|
229
|
+
userinfo(tokens)
|
|
166
230
|
rescue Masks::Client::Error
|
|
167
231
|
{}
|
|
168
232
|
end
|
|
169
233
|
|
|
170
234
|
private
|
|
171
235
|
|
|
236
|
+
def renew(held, scope:, resource:)
|
|
237
|
+
if held&.refresh_token.to_s.empty?
|
|
238
|
+
raise Rejected.new("invalid_grant", "there is no refresh token to renew these tokens with")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
renewed = refresh(held.refresh_token, scope: scope, resource: resource)
|
|
242
|
+
|
|
243
|
+
return renewed if renewed.refresh_token
|
|
244
|
+
|
|
245
|
+
Tokens.from_h(renewed.to_h.merge("refresh_token" => held.refresh_token))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def token(form)
|
|
249
|
+
tokens = Tokens.granted(dpop ? proven(issuer.endpoint("token_endpoint"), form) : post("token_endpoint", form))
|
|
250
|
+
|
|
251
|
+
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")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
tokens
|
|
256
|
+
end
|
|
257
|
+
|
|
172
258
|
def post(endpoint, form)
|
|
173
259
|
url = issuer.endpoint(endpoint)
|
|
174
260
|
|
|
175
261
|
HTTP.post_form(url, form + assertion, authorization)
|
|
176
262
|
end
|
|
177
263
|
|
|
264
|
+
def proven(url, form)
|
|
265
|
+
nonce = issuer.nonces[url]
|
|
266
|
+
response = HTTP.deliver(proven_request(url, form, nonce))
|
|
267
|
+
wanted = issuer.nonces.learn(url, response)
|
|
268
|
+
|
|
269
|
+
begin
|
|
270
|
+
HTTP.parse(response)
|
|
271
|
+
rescue Rejected => e
|
|
272
|
+
raise unless e.code == NO_NONCE && wanted && wanted != nonce
|
|
273
|
+
|
|
274
|
+
response = HTTP.deliver(proven_request(url, form, wanted))
|
|
275
|
+
issuer.nonces.learn(url, response)
|
|
276
|
+
|
|
277
|
+
HTTP.parse(response)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def proven_request(url, form, nonce)
|
|
282
|
+
headers = authorization.merge("DPoP" => dpop.proof("POST", url, nonce: nonce))
|
|
283
|
+
|
|
284
|
+
HTTP.form_request(Net::HTTP::Post, url, form + assertion, headers)
|
|
285
|
+
end
|
|
286
|
+
|
|
178
287
|
def assertion
|
|
179
288
|
return [] if private_key.nil?
|
|
180
289
|
|
data/lib/masks/client/tokens.rb
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Transport
|
|
4
|
+
VERBS = {
|
|
5
|
+
"GET" => Net::HTTP::Get, "HEAD" => Net::HTTP::Head, "POST" => Net::HTTP::Post, "PUT" => Net::HTTP::Put,
|
|
6
|
+
"PATCH" => Net::HTTP::Patch, "DELETE" => Net::HTTP::Delete
|
|
7
|
+
}.freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :origins, :key
|
|
10
|
+
|
|
11
|
+
def initialize(*origins, tokens: nil, key: nil, &renew)
|
|
12
|
+
raise ArgumentError, "a transport needs the origins it may send its tokens to" if origins.empty?
|
|
13
|
+
raise ArgumentError, "a transport needs tokens or a way to fetch them" if tokens.nil? && renew.nil?
|
|
14
|
+
|
|
15
|
+
@origins = origins.flatten.map { |origin| Nonces.origin(HTTP.checked(URI.parse(origin.to_s))) }.uniq.freeze
|
|
16
|
+
@tokens = tokens
|
|
17
|
+
@key = key && Key.from(key)
|
|
18
|
+
@renew = renew
|
|
19
|
+
@lock = Mutex.new
|
|
20
|
+
@nonces = Nonces.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def tokens
|
|
24
|
+
@lock.synchronize do
|
|
25
|
+
@tokens = @renew.call(@tokens) if @renew && (@tokens.nil? || @tokens.expired?)
|
|
26
|
+
@tokens
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def get(url, headers: {})
|
|
31
|
+
request("GET", url, headers: headers)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def head(url, headers: {})
|
|
35
|
+
request("HEAD", url, headers: headers)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def delete(url, headers: {})
|
|
39
|
+
request("DELETE", url, headers: headers)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def post(url, body: nil, headers: {})
|
|
43
|
+
request("POST", url, body: body, headers: headers)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def put(url, body: nil, headers: {})
|
|
47
|
+
request("PUT", url, body: body, headers: headers)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def patch(url, body: nil, headers: {})
|
|
51
|
+
request("PATCH", url, body: body, headers: headers)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def request(method, url, body: nil, headers: {}, limit: nil)
|
|
55
|
+
uri = URI.parse(url.to_s)
|
|
56
|
+
verb = VERBS.fetch(method.to_s.upcase) { raise ArgumentError, "#{method} is not a method a transport sends" }
|
|
57
|
+
|
|
58
|
+
unless origins.include?(Nonces.origin(uri))
|
|
59
|
+
raise Foreign, "this transport holds tokens only for #{origins.join(', ')}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
held = tokens
|
|
63
|
+
nonce = @nonces[uri] if held.bound?
|
|
64
|
+
response = HTTP.deliver(signed(verb, uri, body, headers, held, nonce), limit: limit)
|
|
65
|
+
|
|
66
|
+
return response unless held.bound?
|
|
67
|
+
|
|
68
|
+
wanted = @nonces.learn(uri, response)
|
|
69
|
+
|
|
70
|
+
return response unless wanted && wanted != nonce && nonce_asked?(response)
|
|
71
|
+
|
|
72
|
+
HTTP.deliver(signed(verb, uri, body, headers, held, wanted), limit: limit)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def inspect
|
|
76
|
+
"#<#{self.class.name} #{origins.join(', ')}>"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def signed(verb, uri, body, headers, held, nonce)
|
|
82
|
+
request = verb.new(uri, HTTP.default_headers.merge(headers))
|
|
83
|
+
|
|
84
|
+
if held.bound?
|
|
85
|
+
raise Error, "a DPoP token needs the key it is held to" if key.nil?
|
|
86
|
+
|
|
87
|
+
request["Authorization"] = "#{Proof::SCHEME} #{held.access_token}"
|
|
88
|
+
request["DPoP"] = key.proof(request.method, uri, access_token: held.access_token, nonce: nonce)
|
|
89
|
+
else
|
|
90
|
+
request["Authorization"] = "Bearer #{held.access_token}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
written(request, body)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def written(request, body)
|
|
97
|
+
return request if body.nil?
|
|
98
|
+
|
|
99
|
+
if body.is_a?(String)
|
|
100
|
+
request.body = body
|
|
101
|
+
else
|
|
102
|
+
request.body = JSON.generate(body)
|
|
103
|
+
request["Content-Type"] ||= "application/json"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
request
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def nonce_asked?(response)
|
|
110
|
+
response.code.to_i == 401 && response["WWW-Authenticate"].to_s.include?("use_dpop_nonce")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/masks/client.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "base64"
|
|
2
|
+
require "ipaddr"
|
|
2
3
|
require "json"
|
|
3
4
|
require "jwt"
|
|
4
5
|
require "net/http"
|
|
@@ -9,14 +10,20 @@ require "uri"
|
|
|
9
10
|
require_relative "version"
|
|
10
11
|
require_relative "client/errors"
|
|
11
12
|
require_relative "client/http"
|
|
13
|
+
require_relative "client/embedded"
|
|
12
14
|
require_relative "client/pkce"
|
|
13
15
|
require_relative "client/stores"
|
|
14
16
|
require_relative "client/tracker"
|
|
17
|
+
require_relative "client/nonces"
|
|
18
|
+
require_relative "client/key"
|
|
15
19
|
require_relative "client/issuer"
|
|
16
20
|
require_relative "client/registry"
|
|
17
21
|
require_relative "client/tokens"
|
|
18
22
|
require_relative "client/claims"
|
|
19
23
|
require_relative "client/introspection"
|
|
24
|
+
require_relative "client/enrollment"
|
|
25
|
+
require_relative "client/device"
|
|
26
|
+
require_relative "client/transport"
|
|
20
27
|
require_relative "client/session"
|
|
21
28
|
require_relative "client/verifier"
|
|
22
29
|
require_relative "client/proof"
|
|
@@ -44,6 +51,20 @@ module Masks
|
|
|
44
51
|
# state of its own beyond the discovery cache in ::registry.
|
|
45
52
|
module Client
|
|
46
53
|
class << self
|
|
54
|
+
attr_writer :plain_http
|
|
55
|
+
|
|
56
|
+
def plain_http?
|
|
57
|
+
@plain_http == true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def embed(app, &serves)
|
|
61
|
+
@embedded = app && Embedded.new(app, &serves)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def embedded_for(uri)
|
|
65
|
+
@embedded if @embedded&.serves?(uri)
|
|
66
|
+
end
|
|
67
|
+
|
|
47
68
|
# The process-wide cache of resolved issuers, so discovery is fetched
|
|
48
69
|
# once rather than per request.
|
|
49
70
|
#
|