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.
@@ -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
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
  #
@@ -3,24 +3,19 @@ module Masks
3
3
  module Authentication
4
4
  extend ActiveSupport::Concern
5
5
  include Masks::Rails::Configurable
6
+ include Masks::Rails::Privileged
6
7
 
7
8
  included do
8
9
  if respond_to?(:helper_method)
9
- helper_method :masks_signed_in?, :masks_identity, :masks_tenant, :masks_scopes,
10
- :masks_claims, :masks_organization, :masks_organizations, :masks_role?,
11
- :masks_login_url
12
- end
13
- end
14
-
15
- class_methods do
16
- def masks_members_only!(role: nil, organization: nil, **options)
17
- before_action(-> { authorize_masks_member!(*Array(role), organization: organization) },
18
- **options.slice(:only, :except))
10
+ helper_method :masked, :masks_login_url
19
11
  end
20
12
  end
21
13
 
22
14
  REQUESTS = "masks_requests".freeze
23
15
  HANDSHAKES = "masks_handshakes".freeze
16
+ FAREWELLS = "masks_farewells".freeze
17
+ STEPPED_UP = "masks_stepped_up".freeze
18
+ STEP_UP_WITHIN = 300
24
19
 
25
20
  def masks_session
26
21
  @masks_session ||= masks_config.session_for(request)
@@ -34,6 +29,10 @@ module Masks
34
29
  @masks_handshakes ||= masks_tracker(HANDSHAKES)
35
30
  end
36
31
 
32
+ def masks_farewells
33
+ masks_tracker(FAREWELLS)
34
+ end
35
+
37
36
  def masks_tracker(key)
38
37
  Masks::Client::Tracker.new(Masks::Client::Stores::Session.new(session, key))
39
38
  end
@@ -50,8 +49,7 @@ module Masks
50
49
 
51
50
  session[masks_config.session_key] = held.compact
52
51
  @masks_tokens = tokens
53
- @masks_identity = held["identity"]
54
- @masks_claims = nil
52
+ @masked = nil
55
53
  end
56
54
 
57
55
  def masks_held
@@ -73,8 +71,19 @@ module Masks
73
71
  def masks_forget
74
72
  session.delete(masks_config.session_key)
75
73
  @masks_tokens = nil
76
- @masks_identity = nil
77
- @masks_claims = nil
74
+ @masked = nil
75
+ end
76
+
77
+ def masked
78
+ @masked ||= if masks_tokens
79
+ Masked.new(
80
+ claims: Masks::Client::Claims.new(masks_held["identity"] || {}), scopes: masks_tokens.scopes,
81
+ config: masks_config, signed_in: masks_signed_in?, access_token: masks_tokens.access_token,
82
+ expires_at: masks_tokens.expires_at, account_url: masks_account_url
83
+ )
84
+ else
85
+ Masked.nobody(masks_config)
86
+ end
78
87
  end
79
88
 
80
89
  def masks_signed_in?
@@ -119,46 +128,6 @@ module Masks
119
128
  Masks::Rails::Engine.routes.url_helpers.handshake_path
120
129
  end
121
130
 
122
- def masks_identity
123
- return @masks_identity if defined?(@masks_identity)
124
-
125
- @masks_identity = masks_held["identity"]
126
- end
127
-
128
- def masks_tenant
129
- masks_identity&.dig("tenant")
130
- end
131
-
132
- def masks_claims
133
- @masks_claims ||= Masks::Client::Claims.new(masks_identity || {})
134
- end
135
-
136
- def masks_organization
137
- held = masks_claims.organization
138
-
139
- held.present? ? held : nil
140
- end
141
-
142
- def masks_organizations
143
- masks_claims.organizations
144
- end
145
-
146
- def masks_role?(*roles)
147
- masks_organization&.role?(*roles) || false
148
- end
149
-
150
- def masks_scopes
151
- masks_tokens&.scopes || []
152
- end
153
-
154
- def masks_permits?(scope)
155
- masks_scopes.include?(scope.to_s)
156
- end
157
-
158
- def masks_access_token
159
- masks_tokens&.access_token
160
- end
161
-
162
131
  def masks_refresh!
163
132
  return false unless masks_configured?
164
133
  return false if masks_tokens&.refresh_token.nil?
@@ -188,52 +157,28 @@ module Masks
188
157
  profile.empty? ? nil : held.merge(profile.slice(*IDENTITY))
189
158
  end
190
159
 
191
- def masks_logout_url(return_to: nil)
160
+ def masks_logout_url(state:)
192
161
  return nil unless masks_configured?
193
162
 
194
163
  masks_session.end_session_url(
195
- post_logout_redirect_uri: masks_post_logout_redirect_uri,
196
- state: SecureRandom.urlsafe_base64(16)
164
+ post_logout_redirect_uri: masks_config.return_to_for(request),
165
+ state: state
197
166
  )
198
167
  rescue Masks::Client::Error
199
168
  nil
200
169
  end
201
170
 
202
- def masks_post_logout_redirect_uri
203
- "#{request.base_url}#{masks_local_path(masks_config.after_sign_out) || '/'}"
204
- end
205
-
206
- def masks_login_url(return_to: nil, organization: nil)
171
+ def masks_login_url(return_to: nil, organization: nil, privilege: nil)
207
172
  path = Masks::Rails::Engine.routes.url_helpers.start_path
208
173
  query = {
209
174
  "return_to" => masks_local_path(return_to),
210
- "organization" => Masks::Client::Session.organization_key(organization)
175
+ "organization" => Masks::Client::Session.organization_key(organization),
176
+ "privilege" => Array(privilege).join(" ").presence
211
177
  }.compact
212
178
 
213
179
  query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}"
214
180
  end
215
181
 
216
- def masks_account
217
- identity = masks_identity || {}
218
-
219
- {
220
- "signed_in" => masks_signed_in?,
221
- "subject" => identity["sub"],
222
- "name" => identity["name"],
223
- "nickname" => identity["preferred_username"],
224
- "email" => identity["email"],
225
- "email_verified" => identity["email_verified"],
226
- "picture" => masks_claims.picture,
227
- "avatars" => masks_claims.avatars.to_h.presence,
228
- "tenant" => masks_tenant,
229
- "organization" => masks_organization&.to_h,
230
- "organizations" => masks_organizations.map(&:to_h).presence,
231
- "scopes" => masks_scopes,
232
- "expires_at" => masks_tokens&.expires_at,
233
- "account_url" => masks_account_url
234
- }.compact
235
- end
236
-
237
182
  def masks_account_url
238
183
  "#{masks_config.issuer_for(request).to_s.chomp('/')}/"
239
184
  rescue Masks::Rails::Configuration::Unconfigured
@@ -247,17 +192,63 @@ module Masks
247
192
  false
248
193
  end
249
194
 
250
- def authorize_masks_member!(*roles, organization: nil)
251
- return false unless authenticate_masks!
195
+ private
252
196
 
253
- masks_claims.member!(*roles, organization: organization)
254
- true
255
- rescue Masks::Client::Forbidden => e
256
- masks_forbid(e)
257
- false
258
- end
197
+ def masks_sign_out_upstream
198
+ return nil unless masks_configured?
259
199
 
260
- private
200
+ farewell = masks_farewells.open
201
+ url = masks_logout_url(state: farewell.id)
202
+
203
+ masks_farewells.clear! if url.nil?
204
+ url
205
+ end
206
+
207
+ def masks_after_sign_out_path
208
+ masks_local_path(masks_config.after_sign_out) || "/"
209
+ end
210
+
211
+ def masks_authorize_session!(wanted)
212
+ return false unless authenticate_masks!
213
+
214
+ wanted.each { |privilege| masked.member!(privilege) }
215
+
216
+ missing = wanted.reject { |privilege| (privilege.scopes - masked.scopes).empty? }.map(&:name)
217
+ return true if missing.empty?
218
+
219
+ if masks_asked_already?(session.delete(STEPPED_UP), missing)
220
+ raise Masks::Client::Forbidden.new(
221
+ "insufficient_scope", "this account does not hold the #{missing.join(' and ')} privilege",
222
+ scope: missing.flat_map { |name| masks_config.privilege(name).scopes }.join(" ")
223
+ )
224
+ end
225
+
226
+ masks_ask_again(missing)
227
+ false
228
+ rescue Masks::Client::Forbidden => e
229
+ masks_forbid(e)
230
+ false
231
+ end
232
+
233
+ def masks_asked_already?(asked, missing)
234
+ asked.is_a?(Hash) && (missing - Array(asked["privileges"])).empty? &&
235
+ asked["at"].to_i > Time.now.to_i - STEP_UP_WITHIN
236
+ end
237
+
238
+ def masks_ask_again(missing)
239
+ return_to = request.get? ? request.fullpath : masks_referring_path
240
+ url = masks_login_url(return_to: return_to, privilege: missing)
241
+
242
+ session[STEPPED_UP] = { "privileges" => missing, "at" => Time.now.to_i }
243
+
244
+ if masks_wants_json?
245
+ response.headers["Cache-Control"] = "no-store"
246
+ render json: { "error" => "privilege_required", "privileges" => missing, "login_url" => url },
247
+ status: :forbidden
248
+ else
249
+ redirect_to url
250
+ end
251
+ end
261
252
 
262
253
  def masks_forbid(error)
263
254
  response.headers["Cache-Control"] = "no-store"
@@ -3,14 +3,19 @@ module Masks
3
3
  class Configuration
4
4
  class Unconfigured < Masks::Client::Error; end
5
5
 
6
+ Privilege = Struct.new(:name, :scopes, :roles, :organization, keyword_init: true)
7
+ PRIVILEGE_KEYS = %w[scope role organization].freeze
8
+
9
+ attr_reader :privileges
6
10
  attr_accessor :scope, :namespace, :resource, :resource_scopes, :after_sign_in,
7
11
  :after_sign_out, :session_key, :sign_out_of_issuer, :parent_controller,
8
12
  :credentials_path, :authenticate_everything, :delegates
9
13
  attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget, :logged_out,
10
- :delegation_redirect_uri, :organization, :manages
14
+ :delegation_redirect_uri, :organization, :manages, :replay
11
15
 
12
16
  def initialize
13
17
  @scope = Masks::Client::Session::DEFAULT_SCOPE
18
+ @privileges = {}
14
19
  @resource_scopes = []
15
20
  @after_sign_in = "/"
16
21
  @after_sign_out = "/"
@@ -89,12 +94,12 @@ module Masks
89
94
  true
90
95
  end
91
96
 
92
- def manages?(request, identity = nil)
93
- return false unless @manages.respond_to?(:call)
94
-
95
- held = @manages.arity == 1 ? @manages.call(request) : @manages.call(request, identity)
96
-
97
- held ? true : false
97
+ def manages?(request, masked = nil)
98
+ case @manages
99
+ when Symbol, String then masked&.privilege?(@manages) || false
100
+ when nil then false
101
+ else (@manages.arity == 1 ? @manages.call(request) : @manages.call(request, masked)) ? true : false
102
+ end
98
103
  end
99
104
 
100
105
  def can_forget?
@@ -134,10 +139,15 @@ module Masks
134
139
  Masks::Client::Resource.new(
135
140
  issuer: issuer_for(request),
136
141
  url: url,
137
- scopes: resource_scopes
142
+ scopes: resource_scopes,
143
+ replay: replay
138
144
  )
139
145
  end
140
146
 
147
+ def replay
148
+ @replay || Masks::Client::Proof.memory
149
+ end
150
+
141
151
  def handshake_for(request)
142
152
  Masks::Client::Handshake.new(
143
153
  issuer_for(request),
@@ -151,12 +161,34 @@ module Masks
151
161
  )
152
162
  end
153
163
 
164
+ def privileges=(value)
165
+ @privileges = value.to_h.to_h do |name, spec|
166
+ held = spec.is_a?(Hash) ? spec.transform_keys(&:to_s) : { "scope" => spec }
167
+ unknown = held.keys - PRIVILEGE_KEYS
168
+
169
+ raise ArgumentError, "the #{name} privilege names #{unknown.join(', ')}, which is not #{PRIVILEGE_KEYS.join(', ')}" if unknown.any?
170
+
171
+ [ name.to_s, Privilege.new(name: name.to_s, scopes: Array(held["scope"]).map(&:to_s),
172
+ roles: Array(held["role"]).map(&:to_s), organization: held["organization"]&.to_s) ]
173
+ end
174
+ end
175
+
176
+ def privilege(name)
177
+ privileges.fetch(name.to_s) { raise ArgumentError, "Masks::Rails.config.privileges has no #{name}" }
178
+ end
179
+
180
+ def scope_for(requested)
181
+ wanted = (requested.to_s.split & privileges.keys).flat_map { |name| privileges[name].scopes }
182
+ wanted.empty? ? nil : (Array(scope) + wanted).uniq
183
+ end
184
+
154
185
  def approved_scope
155
186
  delegated = delegates ? [ Masks::Client::Delegations::SCOPE ] : []
156
187
 
157
- return Array(scope) + delegated if namespace.blank?
188
+ asked = (Array(scope) + privileges.values.flat_map(&:scopes)).uniq
189
+ return asked + delegated if namespace.blank?
158
190
 
159
- outside = Array(scope).reject { |name| name.to_s.start_with?(namespace) }
191
+ outside = asked.reject { |name| name.to_s.start_with?(namespace) }
160
192
 
161
193
  outside + [ namespace ] + delegated
162
194
  end
@@ -0,0 +1,126 @@
1
+ module Masks
2
+ module Rails
3
+ class Masked
4
+ attr_reader :claims, :scopes, :access_token, :expires_at, :account_url
5
+
6
+ def self.nobody(config)
7
+ new(claims: Masks::Client::Claims.new({}), scopes: [], config: config, signed_in: false)
8
+ end
9
+
10
+ def initialize(claims:, scopes:, config:, signed_in:, access_token: nil, expires_at: nil, account_url: nil)
11
+ @claims = claims
12
+ @scopes = Array(scopes).map(&:to_s).freeze
13
+ @config = config
14
+ @signed_in = signed_in
15
+ @access_token = access_token
16
+ @expires_at = expires_at
17
+ @account_url = account_url
18
+ end
19
+
20
+ def signed_in?
21
+ @signed_in
22
+ end
23
+
24
+ def [](claim)
25
+ claims[claim]
26
+ end
27
+
28
+ def subject
29
+ claims.subject
30
+ end
31
+
32
+ def name
33
+ claims["name"]
34
+ end
35
+
36
+ def nickname
37
+ claims["preferred_username"]
38
+ end
39
+
40
+ def email
41
+ claims["email"]
42
+ end
43
+
44
+ def email_verified?
45
+ claims["email_verified"] == true
46
+ end
47
+
48
+ def picture
49
+ claims.picture
50
+ end
51
+
52
+ def avatars
53
+ claims.avatars
54
+ end
55
+
56
+ def tenant
57
+ claims.tenant
58
+ end
59
+
60
+ def organization
61
+ held = claims.organization
62
+
63
+ held.present? ? held : nil
64
+ end
65
+
66
+ def organizations
67
+ claims.organizations
68
+ end
69
+
70
+ def role?(*roles)
71
+ organization&.role?(*roles) || false
72
+ end
73
+
74
+ def permits?(scope)
75
+ scopes.include?(scope.to_s)
76
+ end
77
+
78
+ def privileges
79
+ return [] unless signed_in?
80
+
81
+ @config.privileges.values.select { |privilege| holds?(privilege) }.map(&:name)
82
+ end
83
+
84
+ def privilege?(*names)
85
+ wanted = names.flatten.map { |name| @config.privilege(name) }
86
+
87
+ signed_in? && wanted.all? { |privilege| holds?(privilege) }
88
+ end
89
+
90
+ def holds?(privilege)
91
+ return false unless (privilege.scopes - scopes).empty?
92
+
93
+ member!(privilege)
94
+ true
95
+ rescue Masks::Client::Forbidden
96
+ false
97
+ end
98
+
99
+ def member!(privilege)
100
+ return if privilege.roles.empty? && privilege.organization.nil?
101
+
102
+ claims.member!(*privilege.roles, organization: privilege.organization)
103
+ end
104
+
105
+ def to_h
106
+ {
107
+ "signed_in" => signed_in?,
108
+ "subject" => subject,
109
+ "name" => name,
110
+ "nickname" => nickname,
111
+ "email" => email,
112
+ "email_verified" => claims["email_verified"],
113
+ "picture" => picture,
114
+ "avatars" => avatars.to_h.presence,
115
+ "tenant" => claims["tenant"],
116
+ "organization" => organization&.to_h,
117
+ "organizations" => organizations.map(&:to_h).presence,
118
+ "scopes" => scopes,
119
+ "privileges" => privileges,
120
+ "expires_at" => expires_at,
121
+ "account_url" => account_url
122
+ }.compact
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,29 @@
1
+ module Masks
2
+ module Rails
3
+ module Privileged
4
+ extend ActiveSupport::Concern
5
+ include Masks::Rails::Configurable
6
+
7
+ class_methods do
8
+ def masked!(*privileges, **options)
9
+ before_action(-> { authorize_masks!(*privileges) }, **options.slice(:only, :except))
10
+ end
11
+ end
12
+
13
+ def authorize_masks!(*names)
14
+ privileges = names.flatten.map { |name| masks_config.privilege(name) }
15
+
16
+ masks_by_token? ? masks_authorize_token!(privileges) : masks_authorize_session!(privileges)
17
+ end
18
+
19
+ private
20
+
21
+ def masks_by_token?
22
+ return false unless respond_to?(:masks_authorize_token!, true)
23
+ return true if request.authorization.present? || !respond_to?(:masks_authorize_session!, true)
24
+
25
+ masks_tokens.nil?
26
+ end
27
+ end
28
+ end
29
+ end