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.
@@ -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
@@ -4,6 +4,12 @@ module Masks
4
4
 
5
5
  class Unreachable < Error; end
6
6
 
7
+ class Foreign < Error; end
8
+
9
+ class Insecure < Error; end
10
+
11
+ class ReplayUnavailable < Error; end
12
+
7
13
  class Rejected < Error
8
14
  attr_reader :code, :description, :status
9
15
 
@@ -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
- :backchannel_logout_uri
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&.to_s
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
- query << [ "backchannel_logout_uri", backchannel_logout_uri ] if backchannel_logout_uri
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)}"
@@ -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
- uri = URI.parse(url.to_s)
15
- request = Net::HTTP::Post.new(uri, default_headers.merge(headers))
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
- uri = URI.parse(url.to_s)
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
- uri = request.uri
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
- response = Net::HTTP.start(
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 parse(response)
76
- body = begin
77
- JSON.parse(response.body.to_s)
78
- rescue JSON::ParserError
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
@@ -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
@@ -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
@@ -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? || @replay.first?("#{thumbprint}:#{jti}", expires_in: MEMORY)
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)
@@ -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