masks 0.9.2 → 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 +40 -0
- data/README.md +179 -31
- data/app/controllers/masks/rails/avatars_controller.rb +1 -1
- data/app/controllers/masks/rails/handshakes_controller.rb +9 -1
- data/app/controllers/masks/rails/sessions_controller.rb +5 -3
- 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 +84 -93
- data/lib/masks/rails/configuration.rb +42 -10
- data/lib/masks/rails/masked.rb +126 -0
- data/lib/masks/rails/privileged.rb +29 -0
- data/lib/masks/rails/protected_resource.rb +37 -13
- data/lib/masks/rails.rb +2 -0
- data/lib/masks/version.rb +1 -1
- metadata +9 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Enrollment
|
|
4
|
+
attr_reader :initial_access_token, :scope, :expires_in, :obtained_at
|
|
5
|
+
|
|
6
|
+
def self.issued(body)
|
|
7
|
+
if body["initial_access_token"].to_s.empty?
|
|
8
|
+
raise Rejected.new(
|
|
9
|
+
body["error"] || "invalid_enrollment_response",
|
|
10
|
+
body["error_description"] || "the enrollment endpoint answered without an initial_access_token"
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
new(body)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(body)
|
|
18
|
+
@initial_access_token = body["initial_access_token"]
|
|
19
|
+
@scope = body["scope"].to_s
|
|
20
|
+
@expires_in = body["expires_in"].to_i
|
|
21
|
+
@obtained_at = Time.now.to_i
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def expires_at
|
|
25
|
+
obtained_at + expires_in
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def scopes
|
|
29
|
+
scope.split
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/masks/client/errors.rb
CHANGED
data/lib/masks/client/http.rb
CHANGED
|
@@ -3,6 +3,7 @@ module Masks
|
|
|
3
3
|
module HTTP
|
|
4
4
|
OPEN_TIMEOUT = 5
|
|
5
5
|
READ_TIMEOUT = 10
|
|
6
|
+
READ_LIMIT = 1 << 20
|
|
6
7
|
|
|
7
8
|
module_function
|
|
8
9
|
|
|
@@ -11,12 +12,22 @@ module Masks
|
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def post_form(url, form, headers = {})
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
encoded(Net::HTTP::Post, url, form, headers)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def delete_form(url, form, headers = {})
|
|
19
|
+
encoded(Net::HTTP::Delete, url, form, headers)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def encoded(verb, url, form, headers)
|
|
23
|
+
request(form_request(verb, url, form, headers))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def form_request(verb, url, form, headers)
|
|
27
|
+
request = verb.new(URI.parse(url.to_s), default_headers.merge(headers))
|
|
16
28
|
request.body = URI.encode_www_form(form)
|
|
17
29
|
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
18
|
-
|
|
19
|
-
self.request(request)
|
|
30
|
+
request
|
|
20
31
|
end
|
|
21
32
|
|
|
22
33
|
def post_json(url, body, headers = {})
|
|
@@ -32,7 +43,10 @@ module Masks
|
|
|
32
43
|
end
|
|
33
44
|
|
|
34
45
|
def fetch(url, headers = {})
|
|
35
|
-
uri = URI.parse(url.to_s)
|
|
46
|
+
uri = checked(URI.parse(url.to_s))
|
|
47
|
+
embedded = Masks::Client.embedded_for(uri)
|
|
48
|
+
|
|
49
|
+
return embedded.deliver(Net::HTTP::Get.new(uri, headers)) if embedded
|
|
36
50
|
|
|
37
51
|
Net::HTTP.start(
|
|
38
52
|
uri.hostname, uri.port,
|
|
@@ -58,26 +72,62 @@ module Masks
|
|
|
58
72
|
end
|
|
59
73
|
|
|
60
74
|
def request(request)
|
|
61
|
-
|
|
75
|
+
parse(deliver(request))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def deliver(request, limit: READ_LIMIT)
|
|
79
|
+
uri = checked(request.uri)
|
|
80
|
+
embedded = Masks::Client.embedded_for(uri)
|
|
62
81
|
|
|
63
|
-
|
|
82
|
+
return embedded.deliver(request, limit: limit) if embedded
|
|
83
|
+
|
|
84
|
+
Net::HTTP.start(
|
|
64
85
|
uri.hostname, uri.port,
|
|
65
86
|
use_ssl: uri.scheme == "https",
|
|
66
87
|
open_timeout: OPEN_TIMEOUT,
|
|
67
88
|
read_timeout: READ_TIMEOUT
|
|
68
|
-
) { |http| http.request(request) }
|
|
69
|
-
|
|
70
|
-
parse(response)
|
|
89
|
+
) { |http| limit ? limited(http, request, limit) : http.request(request) }
|
|
71
90
|
rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
|
|
72
91
|
raise Unreachable, "#{uri.host} is unreachable (#{e.class})"
|
|
73
92
|
end
|
|
74
93
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
def checked(uri)
|
|
95
|
+
return uri if uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty?
|
|
96
|
+
return uri if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && (Masks::Client.plain_http? || loopback?(uri.hostname))
|
|
97
|
+
|
|
98
|
+
if uri.is_a?(URI::HTTP)
|
|
99
|
+
raise Insecure, "#{Nonces.origin(uri)} is plain http, which masks allows only for loopback hosts " \
|
|
100
|
+
"unless Masks::Client.plain_http is set"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
raise Error, "#{uri} is not an http or https URL"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def loopback?(host)
|
|
107
|
+
name = host.to_s.downcase
|
|
108
|
+
|
|
109
|
+
return true if name == "localhost" || name.end_with?(".localhost")
|
|
110
|
+
|
|
111
|
+
IPAddr.new(name).loopback?
|
|
112
|
+
rescue IPAddr::Error
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def limited(http, request, limit)
|
|
117
|
+
http.request(request) do |response|
|
|
118
|
+
body = +""
|
|
119
|
+
|
|
120
|
+
response.read_body do |chunk|
|
|
121
|
+
body << chunk
|
|
122
|
+
raise Error, "#{request.uri.host} answered with more than #{limit} bytes" if body.bytesize > limit
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
response.body = body
|
|
80
126
|
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse(response)
|
|
130
|
+
body = decoded(response)
|
|
81
131
|
|
|
82
132
|
unless response.is_a?(Net::HTTPSuccess)
|
|
83
133
|
refusal = Unregistered.raised_by?(body) ? Unregistered : Rejected
|
|
@@ -91,6 +141,30 @@ module Masks
|
|
|
91
141
|
|
|
92
142
|
body
|
|
93
143
|
end
|
|
144
|
+
|
|
145
|
+
def decoded(response)
|
|
146
|
+
raw = response.body.to_s
|
|
147
|
+
return {} if raw.strip.empty?
|
|
148
|
+
|
|
149
|
+
held = JSON.parse(raw)
|
|
150
|
+
|
|
151
|
+
return held if held.is_a?(Hash)
|
|
152
|
+
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
153
|
+
|
|
154
|
+
raise Rejected.new("invalid_response", "#{origin(response)} answered with JSON that is not an object",
|
|
155
|
+
status: response.code.to_i)
|
|
156
|
+
rescue JSON::ParserError
|
|
157
|
+
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
158
|
+
|
|
159
|
+
raise Rejected.new("invalid_response", "#{origin(response)} answered with something other than JSON",
|
|
160
|
+
status: response.code.to_i)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def origin(response)
|
|
164
|
+
uri = response.uri
|
|
165
|
+
|
|
166
|
+
uri ? Nonces.origin(uri) : "the server"
|
|
167
|
+
end
|
|
94
168
|
end
|
|
95
169
|
end
|
|
96
170
|
end
|
data/lib/masks/client/issuer.rb
CHANGED
|
@@ -14,13 +14,14 @@ module Masks
|
|
|
14
14
|
Masks::Client.registry[issuer, ttl: ttl]
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
attr_reader :url
|
|
17
|
+
attr_reader :url, :nonces
|
|
18
18
|
|
|
19
19
|
def initialize(url, ttl: TTL)
|
|
20
20
|
@url = self.class.normalize(url)
|
|
21
21
|
@ttl = ttl
|
|
22
22
|
@cache = {}
|
|
23
23
|
@lock = Mutex.new
|
|
24
|
+
@nonces = Nonces.new
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def discovery
|
|
@@ -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
|
|