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.
@@ -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" => fallback(attributes[:grant_types], %w[authorization_code]),
21
- "response_types" => fallback(attributes[:response_types], %w[code]),
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.fallback(value, default)
32
- list = Array(value)
33
- list.empty? ? default : list
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(uri, authorization) }
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(uri, self.class.stringify(attributes), authorization) }
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(uri, authorization) }
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:, scope: Session::DEFAULT_SCOPE)
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
@@ -57,7 +57,7 @@ module Masks
57
57
 
58
58
  parameters = [
59
59
  [ "error", error.code ],
60
- [ "error_description", error.description ],
60
+ [ "error_description", (error.description if error.code) ],
61
61
  [ "scope", error.scope || (scopes.join(" ") if scopes.any?) ],
62
62
  [ "resource_metadata", metadata_url ]
63
63
  ]
@@ -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
- attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scope, :private_key, :key_id
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?(String) ? OpenSSL::PKey.read(private_key) : private_key
26
- @key_id = key_id
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
- Tokens.granted(post("token_endpoint", form))
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
- Tokens.granted(post("token_endpoint", form))
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
- Tokens.granted(post("token_endpoint", form))
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
- Tokens.granted(post("token_endpoint", form))
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(access_token)
151
- HTTP.get(issuer.endpoint("userinfo_endpoint"), "Authorization" => "Bearer #{access_token}")
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.access_token)
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
 
@@ -44,6 +44,10 @@ module Masks
44
44
  scope.split(/\s+/).reject(&:empty?)
45
45
  end
46
46
 
47
+ def bound?
48
+ token_type.to_s.casecmp?(Proof::SCHEME)
49
+ end
50
+
47
51
  def authorization
48
52
  "#{token_type} #{access_token}"
49
53
  end
@@ -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
@@ -28,8 +28,16 @@ module Masks
28
28
  typed!(header["typ"], Array(typ))
29
29
 
30
30
  claims
31
- rescue JWT::DecodeError => e
32
- raise InvalidToken, e.message
31
+ rescue JWT::ExpiredSignature
32
+ raise InvalidToken, "that token has expired"
33
+ rescue JWT::InvalidIssuerError
34
+ raise InvalidToken, "that token was issued by another server"
35
+ rescue JWT::InvalidAudError
36
+ raise InvalidToken, "that token was issued for another app"
37
+ rescue JWT::MissingRequiredClaim
38
+ raise InvalidToken, "that token is missing a claim it needs"
39
+ rescue JWT::DecodeError
40
+ raise InvalidToken, "that token was not signed by #{issuer.url}"
33
41
  end
34
42
 
35
43
  private
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"
@@ -39,11 +46,22 @@ module Masks
39
46
  # [verifier] an API checking a bearer it was handed
40
47
  # [resource] an API publishing what it is and which scopes it takes
41
48
  # [handshake] an app registering itself, once, without a copied secret
42
- #
43
- # Everything reachable from here talks HTTP to a masks issuer and holds no
44
- # state of its own beyond the discovery cache in ::registry.
45
49
  module Client
46
50
  class << self
51
+ attr_writer :plain_http
52
+
53
+ def plain_http?
54
+ @plain_http == true
55
+ end
56
+
57
+ def embed(app, &serves)
58
+ @embedded = app && Embedded.new(app, &serves)
59
+ end
60
+
61
+ def embedded_for(uri)
62
+ @embedded if @embedded&.serves?(uri)
63
+ end
64
+
47
65
  # The process-wide cache of resolved issuers, so discovery is fetched
48
66
  # once rather than per request.
49
67
  #
@@ -57,7 +75,6 @@ module Masks
57
75
  # cheap after the first.
58
76
  #
59
77
  # issuer = Masks::Client.issuer("https://auth.example")
60
- # issuer.authorization_url(client_id: id, redirect_uri: uri)
61
78
  #
62
79
  # @return [Masks::Client::Issuer]
63
80
  def issuer(url, **options)