masks 0.10.0 → 0.12.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 +63 -0
- data/README.md +28 -341
- data/app/controllers/masks/rails/handshakes_controller.rb +2 -2
- data/app/controllers/masks/rails/logouts_controller.rb +11 -8
- data/app/controllers/masks/rails/sessions_controller.rb +48 -6
- data/config/routes.rb +1 -0
- 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/handshake.rb +12 -4
- data/lib/masks/client/http.rb +87 -24
- data/lib/masks/client/issuer.rb +2 -1
- data/lib/masks/client/key.rb +100 -0
- data/lib/masks/client/logout.rb +5 -1
- 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/resource.rb +1 -1
- 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/verifier.rb +10 -2
- data/lib/masks/client.rb +21 -4
- data/lib/masks/rails/authentication.rb +115 -35
- data/lib/masks/rails/cache.rb +173 -0
- data/lib/masks/rails/configuration.rb +27 -12
- data/lib/masks/rails/protected_resource.rb +13 -0
- data/lib/masks/rails/renewal.rb +54 -0
- data/lib/masks/rails/session_cookies.rb +145 -0
- data/lib/masks/rails.rb +5 -4
- data/lib/masks/version.rb +1 -1
- data/lib/masks.rb +0 -1
- metadata +10 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Device
|
|
4
|
+
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code".freeze
|
|
5
|
+
INTERVAL = 5
|
|
6
|
+
REQUIRED = %w[device_code user_code verification_uri].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :device_code, :user_code, :verification_uri, :verification_uri_complete, :expires_in, :interval,
|
|
9
|
+
:obtained_at
|
|
10
|
+
|
|
11
|
+
def self.issued(body)
|
|
12
|
+
missing = REQUIRED.find { |field| body[field].to_s.empty? }
|
|
13
|
+
|
|
14
|
+
if missing
|
|
15
|
+
raise Rejected.new(
|
|
16
|
+
body["error"] || "invalid_device_authorization_response",
|
|
17
|
+
body["error_description"] || "the device authorization endpoint answered without a #{missing}"
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
new(body)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(body)
|
|
25
|
+
@device_code = body["device_code"]
|
|
26
|
+
@user_code = body["user_code"]
|
|
27
|
+
@verification_uri = body["verification_uri"]
|
|
28
|
+
@verification_uri_complete = body["verification_uri_complete"]
|
|
29
|
+
@expires_in = body["expires_in"].to_i
|
|
30
|
+
@interval = body["interval"].to_i
|
|
31
|
+
@obtained_at = Time.now.to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def expires_at
|
|
35
|
+
obtained_at + expires_in
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def expired?
|
|
39
|
+
Time.now.to_i > expires_at
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inspect
|
|
43
|
+
"#<#{self.class.name} #{user_code} for #{verification_uri}>"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "stringio"
|
|
2
|
+
|
|
3
|
+
module Masks
|
|
4
|
+
module Client
|
|
5
|
+
class Embedded
|
|
6
|
+
LOOPBACK = "127.0.0.1".freeze
|
|
7
|
+
DROPPED = %w[host accept-encoding connection content-length].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :app
|
|
10
|
+
|
|
11
|
+
def initialize(app, &serves)
|
|
12
|
+
raise ArgumentError, "an embedded issuer needs the app that serves it" if app.nil?
|
|
13
|
+
raise ArgumentError, "an embedded issuer needs a block naming the URLs it serves" if serves.nil?
|
|
14
|
+
|
|
15
|
+
@app = app
|
|
16
|
+
@serves = serves
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def serves?(uri)
|
|
20
|
+
@serves.call(uri) ? true : false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def deliver(request, limit: nil)
|
|
24
|
+
uri = request.uri
|
|
25
|
+
status, headers, body = answer(environment(request, uri), uri, limit)
|
|
26
|
+
|
|
27
|
+
parsed(request, raw(status, headers, body))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def inspect
|
|
31
|
+
"#<#{self.class.name} #{app.inspect}>"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def answer(env, uri, limit)
|
|
37
|
+
worker = Thread.new { collected(*app.call(env), uri, limit) }
|
|
38
|
+
worker.report_on_exception = false
|
|
39
|
+
|
|
40
|
+
unless worker.join(HTTP::READ_TIMEOUT)
|
|
41
|
+
worker.kill
|
|
42
|
+
raise Unreachable, "#{uri.host} did not answer within #{HTTP::READ_TIMEOUT} seconds"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
worker.value
|
|
46
|
+
rescue Error
|
|
47
|
+
raise
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
raise Unreachable, "#{uri.host} failed inside this process (#{e.class})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def collected(status, headers, body, uri, limit)
|
|
53
|
+
held = "".b
|
|
54
|
+
|
|
55
|
+
body.each do |chunk|
|
|
56
|
+
held << chunk.to_s.b
|
|
57
|
+
raise Error, "#{uri.host} answered with more than #{limit} bytes" if limit && held.bytesize > limit
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
[ status.to_i, headers, held ]
|
|
61
|
+
ensure
|
|
62
|
+
body.close if body.respond_to?(:close)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def environment(request, uri)
|
|
66
|
+
input = request.body.to_s.b
|
|
67
|
+
|
|
68
|
+
env = {
|
|
69
|
+
"REQUEST_METHOD" => request.method,
|
|
70
|
+
"SCRIPT_NAME" => "",
|
|
71
|
+
"PATH_INFO" => uri.path.to_s.empty? ? "/" : uri.path,
|
|
72
|
+
"QUERY_STRING" => uri.query.to_s,
|
|
73
|
+
"SERVER_NAME" => uri.host,
|
|
74
|
+
"SERVER_PORT" => uri.port.to_s,
|
|
75
|
+
"SERVER_PROTOCOL" => "HTTP/1.1",
|
|
76
|
+
"REMOTE_ADDR" => LOOPBACK,
|
|
77
|
+
"HTTP_HOST" => Nonces.origin(uri).delete_prefix("#{uri.scheme}://"),
|
|
78
|
+
"HTTPS" => uri.scheme == "https" ? "on" : "off",
|
|
79
|
+
"rack.url_scheme" => uri.scheme,
|
|
80
|
+
"rack.input" => StringIO.new(input),
|
|
81
|
+
"rack.errors" => $stderr
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
env["CONTENT_LENGTH"] = input.bytesize.to_s unless input.empty?
|
|
85
|
+
|
|
86
|
+
request.each_header do |name, value|
|
|
87
|
+
next if DROPPED.include?(name)
|
|
88
|
+
|
|
89
|
+
key = name.upcase.tr("-", "_")
|
|
90
|
+
env[key == "CONTENT_TYPE" ? key : "HTTP_#{key}"] = value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
env
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def raw(status, headers, body)
|
|
97
|
+
lines = [ "HTTP/1.1 #{status} #{reason(status)}" ]
|
|
98
|
+
|
|
99
|
+
headers.each do |name, value|
|
|
100
|
+
next if %w[content-length transfer-encoding].include?(name.to_s.downcase)
|
|
101
|
+
|
|
102
|
+
Array(value).flat_map { |held| held.to_s.split(/\r?\n/) }.each { |held| lines << "#{name}: #{held}" }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
lines << "content-length: #{body.bytesize}"
|
|
106
|
+
|
|
107
|
+
"#{lines.join("\r\n")}\r\n\r\n".b + body
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parsed(request, raw)
|
|
111
|
+
io = Net::BufferedIO.new(StringIO.new(raw))
|
|
112
|
+
response = Net::HTTPResponse.read_new(io)
|
|
113
|
+
response.uri = request.uri
|
|
114
|
+
response.reading_body(io, request.response_body_permitted?) { response.body }
|
|
115
|
+
response
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def reason(status)
|
|
119
|
+
named = Net::HTTPResponse::CODE_TO_OBJ[status.to_s]
|
|
120
|
+
|
|
121
|
+
named ? named.name.delete_prefix("Net::HTTP").gsub(/(?<=[a-z])(?=[A-Z])/, " ") : "Unknown"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -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
|
@@ -6,17 +6,22 @@ module Masks
|
|
|
6
6
|
AUTH_METHOD = "client_secret_basic".freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :issuer, :name, :resource, :redirect_uris, :scope, :return_to,
|
|
9
|
-
:
|
|
9
|
+
:post_logout_redirect_uri
|
|
10
10
|
|
|
11
11
|
def initialize(issuer, name:, resource:, redirect_uris:, return_to:,
|
|
12
|
-
scope: Session::DEFAULT_SCOPE, backchannel_logout_uri: nil)
|
|
12
|
+
scope: Session::DEFAULT_SCOPE, backchannel_logout_uri: nil, post_logout_redirect_uri: nil)
|
|
13
13
|
@issuer = Issuer.resolve(issuer)
|
|
14
14
|
@name = name.to_s
|
|
15
15
|
@resource = resource.to_s
|
|
16
16
|
@redirect_uris = Array(redirect_uris).map(&:to_s)
|
|
17
17
|
@scope = Array(scope).flat_map { |value| value.to_s.split(/\s+/) }.reject(&:empty?)
|
|
18
18
|
@return_to = return_to.to_s
|
|
19
|
-
@backchannel_logout_uri = backchannel_logout_uri
|
|
19
|
+
@backchannel_logout_uri = backchannel_logout_uri.to_s if present?(backchannel_logout_uri)
|
|
20
|
+
@post_logout_redirect_uri = post_logout_redirect_uri.to_s if present?(post_logout_redirect_uri)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def backchannel_logout_uri
|
|
24
|
+
@backchannel_logout_uri if @backchannel_logout_uri && issuer.backchannel_logout?
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
def endpoint
|
|
@@ -36,7 +41,10 @@ module Masks
|
|
|
36
41
|
[ "state", state ]
|
|
37
42
|
]
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
backchannel = backchannel_logout_uri
|
|
45
|
+
|
|
46
|
+
query << [ "backchannel_logout_uri", backchannel ] if backchannel
|
|
47
|
+
query << [ "post_logout_redirect_uri", post_logout_redirect_uri ] if post_logout_redirect_uri
|
|
40
48
|
redirect_uris.each { |uri| query << [ "redirect_uris", uri ] }
|
|
41
49
|
|
|
42
50
|
"#{endpoint}?#{URI.encode_www_form(query)}"
|
data/lib/masks/client/http.rb
CHANGED
|
@@ -3,6 +3,8 @@ module Masks
|
|
|
3
3
|
module HTTP
|
|
4
4
|
OPEN_TIMEOUT = 5
|
|
5
5
|
READ_TIMEOUT = 10
|
|
6
|
+
READ_LIMIT = 1 << 20
|
|
7
|
+
FETCH_LIMIT = 8 << 20
|
|
6
8
|
|
|
7
9
|
module_function
|
|
8
10
|
|
|
@@ -11,12 +13,22 @@ module Masks
|
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
def post_form(url, form, headers = {})
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
encoded(Net::HTTP::Post, url, form, headers)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def delete_form(url, form, headers = {})
|
|
20
|
+
encoded(Net::HTTP::Delete, url, form, headers)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def encoded(verb, url, form, headers)
|
|
24
|
+
request(form_request(verb, url, form, headers))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def form_request(verb, url, form, headers)
|
|
28
|
+
request = verb.new(URI.parse(url.to_s), default_headers.merge(headers))
|
|
16
29
|
request.body = URI.encode_www_form(form)
|
|
17
30
|
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
18
|
-
|
|
19
|
-
self.request(request)
|
|
31
|
+
request
|
|
20
32
|
end
|
|
21
33
|
|
|
22
34
|
def post_json(url, body, headers = {})
|
|
@@ -32,16 +44,7 @@ module Masks
|
|
|
32
44
|
end
|
|
33
45
|
|
|
34
46
|
def fetch(url, headers = {})
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
Net::HTTP.start(
|
|
38
|
-
uri.hostname, uri.port,
|
|
39
|
-
use_ssl: uri.scheme == "https",
|
|
40
|
-
open_timeout: OPEN_TIMEOUT,
|
|
41
|
-
read_timeout: READ_TIMEOUT
|
|
42
|
-
) { |http| http.request(Net::HTTP::Get.new(uri, headers)) }
|
|
43
|
-
rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
|
|
44
|
-
raise Unreachable, "#{uri.host} is unreachable (#{e.class})"
|
|
47
|
+
deliver(Net::HTTP::Get.new(checked(URI.parse(url.to_s)), headers), limit: FETCH_LIMIT)
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
def json(verb, url, body, headers)
|
|
@@ -58,26 +61,62 @@ module Masks
|
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def request(request)
|
|
61
|
-
|
|
64
|
+
parse(deliver(request))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def deliver(request, limit: READ_LIMIT)
|
|
68
|
+
uri = checked(request.uri)
|
|
69
|
+
embedded = Masks::Client.embedded_for(uri)
|
|
70
|
+
|
|
71
|
+
return embedded.deliver(request, limit: limit) if embedded
|
|
62
72
|
|
|
63
|
-
|
|
73
|
+
Net::HTTP.start(
|
|
64
74
|
uri.hostname, uri.port,
|
|
65
75
|
use_ssl: uri.scheme == "https",
|
|
66
76
|
open_timeout: OPEN_TIMEOUT,
|
|
67
77
|
read_timeout: READ_TIMEOUT
|
|
68
|
-
) { |http| http.request(request) }
|
|
69
|
-
|
|
70
|
-
parse(response)
|
|
78
|
+
) { |http| limit ? limited(http, request, limit) : http.request(request) }
|
|
71
79
|
rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
|
|
72
80
|
raise Unreachable, "#{uri.host} is unreachable (#{e.class})"
|
|
73
81
|
end
|
|
74
82
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
def checked(uri)
|
|
84
|
+
return uri if uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty?
|
|
85
|
+
return uri if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && (Masks::Client.plain_http? || loopback?(uri.hostname))
|
|
86
|
+
|
|
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"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
raise Error, "#{uri} is not an http or https URL"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def loopback?(host)
|
|
96
|
+
name = host.to_s.downcase
|
|
97
|
+
|
|
98
|
+
return true if name == "localhost" || name.end_with?(".localhost")
|
|
99
|
+
|
|
100
|
+
IPAddr.new(name).loopback?
|
|
101
|
+
rescue IPAddr::Error
|
|
102
|
+
false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def limited(http, request, limit)
|
|
106
|
+
http.request(request) do |response|
|
|
107
|
+
body = +""
|
|
108
|
+
|
|
109
|
+
response.read_body do |chunk|
|
|
110
|
+
body << chunk
|
|
111
|
+
raise Error, "#{request.uri.host} answered with more than #{limit} bytes" if body.bytesize > limit
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
response.body = body
|
|
80
115
|
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse(response)
|
|
119
|
+
body = decoded(response)
|
|
81
120
|
|
|
82
121
|
unless response.is_a?(Net::HTTPSuccess)
|
|
83
122
|
refusal = Unregistered.raised_by?(body) ? Unregistered : Rejected
|
|
@@ -91,6 +130,30 @@ module Masks
|
|
|
91
130
|
|
|
92
131
|
body
|
|
93
132
|
end
|
|
133
|
+
|
|
134
|
+
def decoded(response)
|
|
135
|
+
raw = response.body.to_s
|
|
136
|
+
return {} if raw.strip.empty?
|
|
137
|
+
|
|
138
|
+
held = JSON.parse(raw)
|
|
139
|
+
|
|
140
|
+
return held if held.is_a?(Hash)
|
|
141
|
+
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
142
|
+
|
|
143
|
+
raise Rejected.new("invalid_response", "#{origin(response)} answered with JSON that is not an object",
|
|
144
|
+
status: response.code.to_i)
|
|
145
|
+
rescue JSON::ParserError
|
|
146
|
+
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
147
|
+
|
|
148
|
+
raise Rejected.new("invalid_response", "#{origin(response)} answered with something other than JSON",
|
|
149
|
+
status: response.code.to_i)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def origin(response)
|
|
153
|
+
uri = response.uri
|
|
154
|
+
|
|
155
|
+
uri ? Nonces.origin(uri) : "the server"
|
|
156
|
+
end
|
|
94
157
|
end
|
|
95
158
|
end
|
|
96
159
|
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
|
data/lib/masks/client/logout.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Masks
|
|
|
9
9
|
def verify(token, issuer:, audience:, algorithms: ALGORITHMS)
|
|
10
10
|
held = Verifier
|
|
11
11
|
.new(issuer, audience: audience, algorithms: algorithms)
|
|
12
|
-
.verify(token, required: %w[iss aud iat jti events], typ: Verifier::LOGOUT_TOKEN)
|
|
12
|
+
.verify(token, required: %w[iss aud iat exp jti events], typ: Verifier::LOGOUT_TOKEN)
|
|
13
13
|
|
|
14
14
|
new(held).validate!
|
|
15
15
|
end
|
|
@@ -37,6 +37,10 @@ module Masks
|
|
|
37
37
|
Time.at(claims["iat"].to_i).utc
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def expires_at
|
|
41
|
+
Time.at(claims["exp"].to_i).utc
|
|
42
|
+
end
|
|
43
|
+
|
|
40
44
|
def validate!
|
|
41
45
|
refuse!("logout token carries a nonce, so it is an id token") if claims.key?("nonce")
|
|
42
46
|
refuse!("logout token names neither a subject nor a session") if subject.nil? && sid.nil?
|
|
@@ -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
|