masks-server 0.7.0 → 0.9.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.
Files changed (208) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/masks/server/client_authentication.rb +1 -1
  3. data/app/controllers/concerns/masks/server/resource_token.rb +12 -3
  4. data/app/controllers/masks/server/application_controller.rb +8 -6
  5. data/app/controllers/masks/server/authorize_controller.rb +1 -1
  6. data/app/controllers/masks/server/enrollments_controller.rb +66 -0
  7. data/app/controllers/masks/server/introspections_controller.rb +2 -2
  8. data/app/controllers/masks/server/logins_controller.rb +2 -1
  9. data/app/controllers/masks/server/registrations_controller.rb +40 -2
  10. data/app/controllers/masks/server/scim/metadata_controller.rb +1 -1
  11. data/app/controllers/masks/server/tokens_controller.rb +1 -1
  12. data/app/graphql/masks/server/manage/mutations/add_app.rb +1 -1
  13. data/app/graphql/masks/server/manage/types/client_type.rb +8 -0
  14. data/app/graphql/masks/server/manage/types/query_type.rb +6 -1
  15. data/app/helpers/masks/server/account_helper.rb +1 -1
  16. data/app/helpers/masks/server/application_helper.rb +0 -1
  17. data/app/helpers/masks/server/scopes_helper.rb +28 -0
  18. data/app/jobs/masks/server/cleanup_job.rb +1 -0
  19. data/app/models/masks/server/actor.rb +3 -3
  20. data/app/models/masks/server/client.rb +127 -7
  21. data/app/models/masks/server/client_keys.rb +6 -1
  22. data/app/models/masks/server/enrollment_token.rb +54 -0
  23. data/app/models/masks/server/event.rb +3 -0
  24. data/app/models/masks/server/handshake.rb +12 -6
  25. data/app/models/masks/server/issuer.rb +1 -0
  26. data/app/models/masks/server/login.rb +3 -6
  27. data/app/models/masks/server/namespace.rb +6 -3
  28. data/app/models/masks/server/pending_handshake.rb +2 -1
  29. data/app/models/masks/server/replay.rb +22 -4
  30. data/app/models/masks/server/session.rb +7 -5
  31. data/app/models/masks/server/token.rb +1 -0
  32. data/app/policies/masks/server/exchange_policy.rb +1 -1
  33. data/app/policies/masks/server/grant_checks.rb +1 -1
  34. data/app/views/layouts/masks/server/manage.html.erb +2 -2
  35. data/app/views/masks/server/account/_info.html.erb +1 -1
  36. data/app/views/masks/server/account/_organizations.html.erb +4 -4
  37. data/app/views/masks/server/application/_actor.html.erb +27 -0
  38. data/app/views/masks/server/handshakes/refused.html.erb +1 -1
  39. data/app/views/masks/server/handshakes/show.html.erb +129 -59
  40. data/app/views/masks/server/logins/_choose_organization.html.erb +1 -1
  41. data/app/views/masks/server/logins/_consent.html.erb +1 -1
  42. data/app/views/masks/server/sessions/confirm.html.erb +1 -1
  43. data/config/locales/de/account.yml +3 -3
  44. data/config/locales/de/application.yml +1 -1
  45. data/config/locales/de/events.yml +10 -7
  46. data/config/locales/de/flashes.yml +2 -2
  47. data/config/locales/de/handshakes.yml +33 -14
  48. data/config/locales/de/mailers.yml +1 -1
  49. data/config/locales/de/manage/activity.yml +2 -2
  50. data/config/locales/de/manage/actor.yml +41 -23
  51. data/config/locales/de/manage/actors.yml +16 -15
  52. data/config/locales/de/manage/api.yml +1 -1
  53. data/config/locales/de/manage/app.yml +6 -6
  54. data/config/locales/de/manage/client.yml +45 -45
  55. data/config/locales/de/manage/clients.yml +28 -32
  56. data/config/locales/de/manage/connections.yml +2 -2
  57. data/config/locales/de/manage/consents.yml +2 -2
  58. data/config/locales/de/manage/device.yml +7 -7
  59. data/config/locales/de/manage/devices.yml +18 -18
  60. data/config/locales/de/manage/directory.yml +1 -1
  61. data/config/locales/de/manage/domains.yml +6 -6
  62. data/config/locales/de/manage/emails.yml +4 -4
  63. data/config/locales/de/manage/events.yml +3 -0
  64. data/config/locales/de/manage/groups.yml +7 -7
  65. data/config/locales/de/manage/ldap.yml +8 -8
  66. data/config/locales/de/manage/namespaces.yml +1 -1
  67. data/config/locales/de/manage/organization.yml +15 -8
  68. data/config/locales/de/manage/organizations.yml +3 -3
  69. data/config/locales/de/manage/policies.yml +6 -6
  70. data/config/locales/de/manage/policy_rules.yml +1 -1
  71. data/config/locales/de/manage/presence.yml +6 -6
  72. data/config/locales/de/manage/providers.yml +70 -48
  73. data/config/locales/de/manage/provisioning.yml +4 -4
  74. data/config/locales/de/manage/senders.yml +1 -1
  75. data/config/locales/de/manage/settings.yml +37 -26
  76. data/config/locales/de/manage/settings_shell.yml +2 -2
  77. data/config/locales/de/refusals.yml +5 -5
  78. data/config/locales/en/account.yml +5 -5
  79. data/config/locales/en/application.yml +1 -1
  80. data/config/locales/en/events.yml +9 -6
  81. data/config/locales/en/flashes.yml +3 -3
  82. data/config/locales/en/handshakes.yml +33 -14
  83. data/config/locales/en/logins.yml +2 -2
  84. data/config/locales/en/manage/actor.yml +32 -14
  85. data/config/locales/en/manage/actors.yml +16 -15
  86. data/config/locales/en/manage/app.yml +5 -5
  87. data/config/locales/en/manage/client.yml +9 -9
  88. data/config/locales/en/manage/clients.yml +8 -12
  89. data/config/locales/en/manage/connections.yml +1 -1
  90. data/config/locales/en/manage/consents.yml +1 -1
  91. data/config/locales/en/manage/device.yml +1 -1
  92. data/config/locales/en/manage/devices.yml +1 -1
  93. data/config/locales/en/manage/directory.yml +1 -1
  94. data/config/locales/en/manage/domains.yml +6 -6
  95. data/config/locales/en/manage/emails.yml +4 -4
  96. data/config/locales/en/manage/events.yml +3 -0
  97. data/config/locales/en/manage/groups.yml +7 -7
  98. data/config/locales/en/manage/ldap.yml +8 -8
  99. data/config/locales/en/manage/messages.yml +1 -1
  100. data/config/locales/en/manage/namespaces.yml +1 -1
  101. data/config/locales/en/manage/organization.yml +16 -9
  102. data/config/locales/en/manage/organizations.yml +3 -3
  103. data/config/locales/en/manage/policies.yml +6 -6
  104. data/config/locales/en/manage/policy_rules.yml +2 -2
  105. data/config/locales/en/manage/presence.yml +1 -1
  106. data/config/locales/en/manage/providers.yml +70 -48
  107. data/config/locales/en/manage/provisioning.yml +4 -4
  108. data/config/locales/en/manage/senders.yml +2 -2
  109. data/config/locales/en/manage/settings.yml +34 -23
  110. data/config/locales/es/account.yml +3 -3
  111. data/config/locales/es/application.yml +2 -2
  112. data/config/locales/es/events.yml +9 -6
  113. data/config/locales/es/flashes.yml +4 -4
  114. data/config/locales/es/handshakes.yml +33 -14
  115. data/config/locales/es/logins.yml +12 -12
  116. data/config/locales/es/mailers.yml +7 -7
  117. data/config/locales/es/manage/activity.yml +2 -2
  118. data/config/locales/es/manage/actor.yml +33 -15
  119. data/config/locales/es/manage/actors.yml +17 -16
  120. data/config/locales/es/manage/api.yml +1 -1
  121. data/config/locales/es/manage/app.yml +6 -6
  122. data/config/locales/es/manage/client.yml +30 -30
  123. data/config/locales/es/manage/clients.yml +22 -26
  124. data/config/locales/es/manage/connections.yml +1 -1
  125. data/config/locales/es/manage/consents.yml +1 -1
  126. data/config/locales/es/manage/device.yml +1 -1
  127. data/config/locales/es/manage/directory.yml +1 -1
  128. data/config/locales/es/manage/domains.yml +5 -5
  129. data/config/locales/es/manage/emails.yml +7 -7
  130. data/config/locales/es/manage/events.yml +3 -0
  131. data/config/locales/es/manage/groups.yml +7 -7
  132. data/config/locales/es/manage/ldap.yml +8 -8
  133. data/config/locales/es/manage/namespaces.yml +1 -1
  134. data/config/locales/es/manage/organization.yml +16 -9
  135. data/config/locales/es/manage/organizations.yml +3 -3
  136. data/config/locales/es/manage/policies.yml +8 -8
  137. data/config/locales/es/manage/policy_rules.yml +2 -2
  138. data/config/locales/es/manage/providers.yml +70 -48
  139. data/config/locales/es/manage/provisioning.yml +4 -4
  140. data/config/locales/es/manage/senders.yml +1 -1
  141. data/config/locales/es/manage/settings.yml +38 -27
  142. data/config/locales/es/manage/settings_shell.yml +2 -2
  143. data/config/locales/es/refusals.yml +3 -3
  144. data/config/locales/fr/account.yml +2 -2
  145. data/config/locales/fr/application.yml +1 -1
  146. data/config/locales/fr/events.yml +8 -5
  147. data/config/locales/fr/flashes.yml +1 -1
  148. data/config/locales/fr/handshakes.yml +33 -14
  149. data/config/locales/fr/manage/activity.yml +4 -4
  150. data/config/locales/fr/manage/actor.yml +43 -25
  151. data/config/locales/fr/manage/actors.yml +22 -21
  152. data/config/locales/fr/manage/api.yml +1 -1
  153. data/config/locales/fr/manage/app.yml +7 -7
  154. data/config/locales/fr/manage/client.yml +50 -50
  155. data/config/locales/fr/manage/clients.yml +28 -32
  156. data/config/locales/fr/manage/connections.yml +2 -2
  157. data/config/locales/fr/manage/consents.yml +1 -1
  158. data/config/locales/fr/manage/device.yml +1 -1
  159. data/config/locales/fr/manage/directory.yml +1 -1
  160. data/config/locales/fr/manage/domains.yml +6 -6
  161. data/config/locales/fr/manage/emails.yml +5 -5
  162. data/config/locales/fr/manage/events.yml +3 -0
  163. data/config/locales/fr/manage/groups.yml +7 -7
  164. data/config/locales/fr/manage/ldap.yml +8 -8
  165. data/config/locales/fr/manage/namespaces.yml +1 -1
  166. data/config/locales/fr/manage/organization.yml +15 -8
  167. data/config/locales/fr/manage/organizations.yml +3 -3
  168. data/config/locales/fr/manage/policies.yml +6 -6
  169. data/config/locales/fr/manage/policy_rules.yml +1 -1
  170. data/config/locales/fr/manage/providers.yml +70 -48
  171. data/config/locales/fr/manage/provisioning.yml +4 -4
  172. data/config/locales/fr/manage/senders.yml +1 -1
  173. data/config/locales/fr/manage/settings.yml +38 -27
  174. data/config/locales/fr/manage/settings_shell.yml +2 -2
  175. data/config/routes.rb +2 -0
  176. data/config/vite.json +1 -0
  177. data/db/migrate/20261011190000_add_enrolled_by_to_clients.rb +5 -0
  178. data/db/migrate/20261012100000_create_replays.rb +23 -0
  179. data/db/migrate/20261013010000_add_handshake_return_to_to_clients.rb +5 -0
  180. data/lib/generators/masks/server/install/install_generator.rb +2 -2
  181. data/lib/masks/server/browser_access.rb +35 -0
  182. data/lib/masks/server/configuration.rb +3 -3
  183. data/lib/masks/server/engine.rb +1 -0
  184. data/lib/masks/server/schema_reference.rb +17 -4
  185. data/lib/masks/server.rb +1 -0
  186. data/public/masks-assets/.vite/manifest-assets.json +1 -0
  187. data/public/masks-assets/.vite/manifest.json +62 -0
  188. data/public/masks-assets/assets/app-B27oPUll.css +2 -0
  189. data/public/masks-assets/assets/console-DoyNbIw3.css +1 -0
  190. data/public/masks-assets/assets/handshake-DIFZPSAK.js +2 -0
  191. data/public/masks-assets/assets/handshake-DIFZPSAK.js.map +1 -0
  192. data/public/masks-assets/assets/{initial-D99AzFOd.js → initial-C1GpCiEh.js} +2 -2
  193. data/public/masks-assets/assets/{initial-D99AzFOd.js.map → initial-C1GpCiEh.js.map} +1 -1
  194. data/public/masks-assets/assets/login-C_6igRZ3.js +4 -0
  195. data/public/masks-assets/assets/login-C_6igRZ3.js.map +1 -0
  196. data/public/masks-assets/assets/manage-Dbfiy3Mj.js +567 -0
  197. data/public/masks-assets/assets/manage-Dbfiy3Mj.js.map +1 -0
  198. data/version.txt +1 -1
  199. metadata +21 -12
  200. data/app/views/masks/server/application/_person.html.erb +0 -27
  201. data/public/masks-assets/assets/app-olx-5PkA.css +0 -2
  202. data/public/masks-assets/assets/console-CiuQ3qj0.css +0 -1
  203. data/public/masks-assets/assets/handshake-BYhhQ_X3.js +0 -2
  204. data/public/masks-assets/assets/handshake-BYhhQ_X3.js.map +0 -1
  205. data/public/masks-assets/assets/login-CNs1UpxJ.js +0 -4
  206. data/public/masks-assets/assets/login-CNs1UpxJ.js.map +0 -1
  207. data/public/masks-assets/assets/manage-Z3GmPDNQ.js +0 -560
  208. data/public/masks-assets/assets/manage-Z3GmPDNQ.js.map +0 -1
@@ -26,6 +26,7 @@ module Masks
26
26
  CHALLENGE_METHODS = %w[S256].freeze
27
27
  LOOPBACK = %w[localhost 127.0.0.1 ::1].freeze
28
28
  DEFAULT_SCOPES = Scopes::STANDARD
29
+ MACHINE_ACCESS_LIFETIME = 10.minutes
29
30
  LINKS = %i[client_uri tos_uri policy_uri].freeze
30
31
  METADATA_URIS = (LINKS + [ :logo_uri ]).freeze
31
32
  URI_LIMIT = 2048
@@ -47,12 +48,15 @@ module Masks
47
48
  ].freeze
48
49
 
49
50
  class ScopesUnavailable < StandardError; end
51
+ class EnrollmentRefused < StandardError; end
50
52
 
51
53
  has_many :tokens, dependent: :destroy
52
54
  has_many :consents, dependent: :destroy
53
55
  has_many :delegations, dependent: :destroy
54
56
  has_many :saml_participations, dependent: :delete_all
55
57
  has_many :namespaces, -> { order(:name) }, dependent: :nullify
58
+ has_many :enrolled, class_name: "Client", foreign_key: :enrolled_by_id, inverse_of: :enrolled_by,
59
+ dependent: :nullify
56
60
  has_one :logo, class_name: "ClientLogo", dependent: :delete
57
61
 
58
62
  validate :allowed_groups_are_named
@@ -86,8 +90,10 @@ module Masks
86
90
  normalizes(*METADATA_URIS, with: ->(value) { value.to_s.strip.presence })
87
91
 
88
92
  after_commit :refresh_logo!, on: %i[create update], if: :saved_change_to_logo_uri?
93
+ after_update :archived!, if: -> { saved_change_to_archived_at? && archived? }
89
94
 
90
95
  belongs_to :approved_by, class_name: "Actor", optional: true
96
+ belongs_to :enrolled_by, class_name: "Client", optional: true, inverse_of: :enrolled
91
97
  belongs_to :sign_in_policy, optional: true
92
98
 
93
99
  scope :approved, -> { where.not(approved_at: nil) }
@@ -117,7 +123,7 @@ module Masks
117
123
  {
118
124
  name: handshake.name,
119
125
  redirect_uris: handshake.redirect_uris,
120
- post_logout_redirect_uris: [ handshake.return_to ].compact,
126
+ post_logout_redirect_uris: [ handshake.post_logout_redirect_uri ].compact,
121
127
  resources: [ handshake.resource ],
122
128
  allowed_scopes: Scopes.join(handshake.scopes),
123
129
  grant_types: handshake.grant_types,
@@ -125,6 +131,7 @@ module Masks
125
131
  token_endpoint_auth_method: handshake.auth_method,
126
132
  backchannel_logout_uri: handshake.backchannel_logout_uri,
127
133
  client_uri: handshake.origin,
134
+ handshake_return_to: handshake.return_to,
128
135
  dynamic: false
129
136
  }
130
137
  end
@@ -132,7 +139,7 @@ module Masks
132
139
  def approved_for(resource)
133
140
  return nil if resource.blank?
134
141
 
135
- active.approved.where("resources @> ?", [ resource.to_s ].to_json).first
142
+ active.approved.where(enrolled_by_id: nil).where("resources @> ?", [ resource.to_s ].to_json).first
136
143
  end
137
144
 
138
145
  def register!(metadata)
@@ -163,12 +170,48 @@ module Masks
163
170
  name: held[:name] || name,
164
171
  grant_types: grant_types,
165
172
  response_types: held[:response_types].presence || response_types_for(grant_types),
166
- allowed_scopes: Scopes.join(bounded(metadata.to_h.stringify_keys["scope"].presence ||
167
- Current.tenant&.dynamic_client_ceiling || DEFAULT_SCOPES)),
173
+ allowed_scopes: Scopes.join(declared_scopes(metadata)),
168
174
  dynamic: true
169
175
  )
170
176
  end
171
177
 
178
+ def enroll!(token, metadata)
179
+ app = token.client
180
+ app&.lock!
181
+ refusal = app&.enrollment_refusal(token.scope_list)
182
+
183
+ raise EnrollmentRefused, refusal || "the app that issued this enrollment token is gone" if app.nil? || refusal
184
+
185
+ held = metadata.to_h.stringify_keys
186
+
187
+ raise EnrollmentRefused, "jwks_uri is not accepted here; register the public keys as jwks" if held["jwks_uri"].present?
188
+ raise EnrollmentRefused, "jwks is required: the machine signs in with its own key" if held["jwks"].blank?
189
+
190
+ new(
191
+ client_id: SecureRandom.uuid,
192
+ name: held["client_name"].to_s.strip.presence || token.held("name") || "A machine",
193
+ jwks: held["jwks"],
194
+ token_endpoint_auth_method: PRIVATE_KEY_JWT,
195
+ grant_types: [ CLIENT_CREDENTIALS ],
196
+ response_types: [],
197
+ dpop_bound_access_tokens: true,
198
+ resources: app.resources,
199
+ allowed_scopes: Scopes.join(token.scope_list),
200
+ application_type: "native",
201
+ subject_type: Subjects::PUBLIC,
202
+ enrolled_by: app,
203
+ access_token_lifetime: MACHINE_ACCESS_LIFETIME.to_i,
204
+ approved_at: Time.current,
205
+ dynamic: false
206
+ ).issue_credentials!
207
+ end
208
+
209
+ def declared_scopes(metadata)
210
+ declared = metadata.to_h.stringify_keys["scope"]
211
+
212
+ Scopes.list(declared).empty? ? [] : bounded(declared)
213
+ end
214
+
172
215
  def bounded(requested)
173
216
  reserved = Scopes.reserved(requested) + Scopes.list(requested).select { |s| Scopes.prefix?(s) }
174
217
 
@@ -216,6 +259,38 @@ module Masks
216
259
  end
217
260
  end
218
261
 
262
+ def enrollment_refusal(scopes)
263
+ return "an archived client enrolls nothing" if archived?
264
+ return "a public client enrolls nothing" if public?
265
+ return "only an approved client enrolls machines" unless approved?
266
+ return "a client enrolls machines only after it claims a namespace" if namespaces.empty?
267
+ return "a scope is required" if Scopes.list(scopes).empty?
268
+
269
+ held = namespaces.map(&:name)
270
+ outside = Scopes.list(scopes).select do |scope|
271
+ Scopes.prefix?(scope) || scope.start_with?(Scopes::NAMESPACE) || !Scopes.covered?(held, scope)
272
+ end
273
+
274
+ "#{Scopes.join(outside)} is outside #{Scopes.join(held)}" if outside.any?
275
+ end
276
+
277
+ def enrolled?
278
+ enrolled_by_id.present?
279
+ end
280
+
281
+ def unenroll!
282
+ transaction do
283
+ revoked = end_tokens!
284
+ update!(archived_at: Time.current) unless archived?
285
+ revoked
286
+ end
287
+ end
288
+
289
+ def end_tokens!
290
+ now = Time.current
291
+ tokens.where(consumed_at: nil).update_all(consumed_at: now, updated_at: now)
292
+ end
293
+
219
294
  def public?
220
295
  token_endpoint_auth_method == "none"
221
296
  end
@@ -311,7 +386,7 @@ module Masks
311
386
  end
312
387
 
313
388
  def redirect_uri?(candidate)
314
- redirect_uris.include?(candidate.to_s)
389
+ redirect_uris.include?(candidate.to_s) || redirect_uris.any? { |registered| same_loopback?(registered, candidate.to_s) }
315
390
  end
316
391
 
317
392
  def grants?(grant_type)
@@ -355,7 +430,11 @@ module Masks
355
430
  end
356
431
 
357
432
  def scope_list
358
- Scopes.union(required_scopes, allowed_scopes)
433
+ Scopes.union(required_scopes, offered_scopes)
434
+ end
435
+
436
+ def follows_registration?
437
+ dynamic? && Scopes.list(allowed_scopes).empty?
359
438
  end
360
439
 
361
440
  def access_lifetime
@@ -373,7 +452,19 @@ module Masks
373
452
  def permitted_scopes(requested)
374
453
  return scope_list if Scopes.list(requested).empty?
375
454
 
376
- Scopes.union(required_scopes, Scopes.granted(requested, allowed_scopes))
455
+ return Scopes.union(required_scopes, unreserved(requested)) if open_to_anything?
456
+
457
+ Scopes.union(required_scopes, Scopes.granted(requested, offered_scopes))
458
+ end
459
+
460
+ def refused_scopes(requested)
461
+ Scopes.list(requested) - permitted_scopes(requested)
462
+ end
463
+
464
+ def offered_scopes
465
+ return Scopes.list(allowed_scopes) unless follows_registration?
466
+
467
+ Current.tenant&.dynamic_client_ceiling || DEFAULT_SCOPES
377
468
  end
378
469
 
379
470
  def metadata
@@ -412,6 +503,17 @@ module Masks
412
503
 
413
504
  private
414
505
 
506
+ def archived!
507
+ end_tokens!
508
+
509
+ machines = enrolled.active.pluck(:id)
510
+ return if machines.empty?
511
+
512
+ now = Time.current
513
+ Client.where(id: machines).update_all(archived_at: archived_at, updated_at: now)
514
+ Token.where(client_id: machines, consumed_at: nil).update_all(consumed_at: now, updated_at: now)
515
+ end
516
+
415
517
  def allowed_groups_are_named
416
518
  unknown = allowed_groups.grep_v(ALLOWED_GROUP)
417
519
 
@@ -464,6 +566,24 @@ module Masks
464
566
  self.class.loopback?(uri)
465
567
  end
466
568
 
569
+ def open_to_anything?
570
+ follows_registration? && Current.tenant&.dynamic_registration == Tenant::REGISTRATION_ANYTHING
571
+ end
572
+
573
+ def unreserved(requested)
574
+ Scopes.list(requested).reject { |scope| Scopes.prefix?(scope) || Scopes.reserved(scope).any? }
575
+ end
576
+
577
+ def same_loopback?(registered, candidate)
578
+ held = URI.parse(registered)
579
+ asked = URI.parse(candidate)
580
+
581
+ [ held, asked ].all? { |uri| uri.instance_of?(URI::HTTP) && LOOPBACK.include?(uri.hostname) && uri.userinfo.nil? && uri.fragment.nil? } &&
582
+ held.hostname == asked.hostname && held.path == asked.path && held.query == asked.query
583
+ rescue URI::InvalidURIError
584
+ false
585
+ end
586
+
467
587
  def grant_types_are_known
468
588
  unknown = grant_types - GRANT_TYPES
469
589
  errors.add(:grant_types, "not supported: #{unknown.join(', ')}") if unknown.any?
@@ -9,6 +9,8 @@ module Masks
9
9
  SECRET = %w[d p q dp dq qi k].freeze
10
10
  ALGORITHMS = %w[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512].freeze
11
11
  KINDS = %w[RSA EC].freeze
12
+ CURVES = %w[P-256 P-384 P-521].freeze
13
+ SHORTEST_RSA = 2048
12
14
  LEEWAY = 30
13
15
  LONGEST = 1.hour
14
16
 
@@ -23,8 +25,11 @@ module Masks
23
25
  raise Refused, "every key must be a JSON object" unless key.is_a?(Hash)
24
26
  raise Refused, "#{key['kty'].presence || 'that'} is not a kind of key a client signs with" unless KINDS.include?(key["kty"])
25
27
  raise Refused, "a client's keys must be public" if key.keys.any? { |field| SECRET.include?(field.to_s) }
28
+ raise Refused, "#{key['crv'].presence || 'that'} is not a curve a client signs with" if key["kty"] == "EC" && !CURVES.include?(key["crv"])
26
29
 
27
- JWT::JWK.new(key).verify_key
30
+ verified = JWT::JWK.new(key).verify_key
31
+
32
+ raise Refused, "an RSA key must be at least #{SHORTEST_RSA} bits" if verified.is_a?(OpenSSL::PKey::RSA) && verified.n.num_bits < SHORTEST_RSA
28
33
  end
29
34
 
30
35
  held
@@ -0,0 +1,54 @@
1
+ module Masks
2
+ module Server
3
+ class EnrollmentToken < Token
4
+ SHORTEST = 1.minute
5
+ LONGEST = 1.hour
6
+
7
+ class << self
8
+ def lifetime
9
+ 15.minutes
10
+ end
11
+
12
+ def issue!(client, scope:, name: nil, expires_in: nil)
13
+ scopes = Scopes.list(scope)
14
+ refusal = client.enrollment_refusal(scopes)
15
+
16
+ raise Client::EnrollmentRefused, refusal if refusal
17
+
18
+ mint!(
19
+ client: client,
20
+ scopes: Scopes.join(scopes),
21
+ audience: client.resources,
22
+ payload: { "name" => name.to_s.strip.presence }.compact,
23
+ expires_at: lasting(expires_in).from_now
24
+ )
25
+ end
26
+
27
+ def redeem!(secret, metadata)
28
+ transaction(requires_new: true) do
29
+ token = claim(secret)
30
+
31
+ next nil if token.nil?
32
+
33
+ Client.enroll!(token, metadata)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def lasting(expires_in)
40
+ return lifetime if expires_in.blank?
41
+
42
+ seconds = Integer(expires_in.to_s, 10)
43
+
44
+ raise ArgumentError unless seconds.between?(SHORTEST.to_i, LONGEST.to_i)
45
+
46
+ seconds.seconds
47
+ rescue ArgumentError
48
+ raise Client::EnrollmentRefused,
49
+ "expires_in must be between #{SHORTEST.to_i} and #{LONGEST.to_i} seconds"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -106,6 +106,9 @@ module Masks
106
106
  CLIENT_RESTORED = "client.restored".freeze
107
107
  CLIENT_SECRET_ROTATED = "client.secret_rotated".freeze
108
108
  CLIENT_LOGO_REFUSED = "client.logo_refused".freeze
109
+ CLIENT_ENROLLMENT_ISSUED = "client.enrollment_issued".freeze
110
+ CLIENT_ENROLLED = "client.enrolled".freeze
111
+ CLIENT_UNENROLLED = "client.unenrolled".freeze
109
112
 
110
113
  TOKEN_REVOKED = "token.revoked".freeze
111
114
  SAML_ASSERTED = "saml.asserted".freeze
@@ -4,7 +4,7 @@ module Masks
4
4
  GRANT_TYPES = %w[authorization_code refresh_token].freeze
5
5
 
6
6
  attr_reader :name, :redirect_uris, :resource, :scopes, :return_to, :state, :auth_method,
7
- :backchannel_logout_uri
7
+ :backchannel_logout_uri, :post_logout_redirect_uri
8
8
 
9
9
  class << self
10
10
  def from_request(request)
@@ -19,7 +19,8 @@ module Masks
19
19
  return_to: params["return_to"],
20
20
  state: params["state"],
21
21
  auth_method: params["token_endpoint_auth_method"],
22
- backchannel_logout_uri: params["backchannel_logout_uri"]
22
+ backchannel_logout_uri: params["backchannel_logout_uri"],
23
+ post_logout_redirect_uri: params["post_logout_redirect_uri"]
23
24
  )
24
25
  end
25
26
 
@@ -36,7 +37,8 @@ module Masks
36
37
  return_to: row.redirect_uri,
37
38
  state: held["state"],
38
39
  auth_method: held["auth_method"],
39
- backchannel_logout_uri: held["backchannel_logout_uri"]
40
+ backchannel_logout_uri: held["backchannel_logout_uri"],
41
+ post_logout_redirect_uri: held["post_logout_redirect_uri"]
40
42
  )
41
43
  end
42
44
  end
@@ -54,7 +56,8 @@ module Masks
54
56
  "state" => state,
55
57
  "redirect_uris" => redirect_uris.sort,
56
58
  "auth_method" => auth_method,
57
- "backchannel_logout_uri" => backchannel_logout_uri
59
+ "backchannel_logout_uri" => backchannel_logout_uri,
60
+ "post_logout_redirect_uri" => post_logout_redirect_uri
58
61
  }.compact
59
62
  end
60
63
 
@@ -73,12 +76,14 @@ module Masks
73
76
 
74
77
  pairs << [ "state", state ] if state
75
78
  pairs << [ "backchannel_logout_uri", backchannel_logout_uri ] if backchannel_logout_uri
79
+ pairs << [ "post_logout_redirect_uri", post_logout_redirect_uri ] if post_logout_redirect_uri
76
80
  redirect_uris.each { |uri| pairs << [ "redirect_uris", uri ] }
77
81
  pairs
78
82
  end
79
83
 
80
84
  def initialize(name: nil, redirect_uris: nil, resource: nil, scopes: nil,
81
- return_to: nil, state: nil, auth_method: nil, backchannel_logout_uri: nil)
85
+ return_to: nil, state: nil, auth_method: nil, backchannel_logout_uri: nil,
86
+ post_logout_redirect_uri: nil)
82
87
  @name = name.to_s.strip.presence || "An application"
83
88
  @redirect_uris = Array(redirect_uris).map(&:to_s).reject(&:empty?).uniq
84
89
  @resource = resource.to_s
@@ -87,6 +92,7 @@ module Masks
87
92
  @state = state.presence
88
93
  @auth_method = auth_method.to_s.presence || Client::DEFAULT_AUTH_METHOD
89
94
  @backchannel_logout_uri = backchannel_logout_uri.to_s.strip.presence
95
+ @post_logout_redirect_uri = post_logout_redirect_uri.to_s.strip.presence
90
96
  end
91
97
 
92
98
  def origin
@@ -134,7 +140,7 @@ module Masks
134
140
  return "token_endpoint_auth_method must be one of #{Client::AUTH_METHODS.join(', ')}"
135
141
  end
136
142
 
137
- elsewhere = ([ return_to ] + redirect_uris + Array(backchannel_logout_uri))
143
+ elsewhere = ([ return_to ] + redirect_uris + Array(backchannel_logout_uri) + Array(post_logout_redirect_uri))
138
144
  .reject { |uri| origin_of(uri) == origin }
139
145
  return "everything must share the origin #{origin}: #{elsewhere.join(', ')}" if elsewhere.any?
140
146
 
@@ -163,6 +163,7 @@ module Masks
163
163
  "pushed_authorization_request_endpoint" => "#{url}/par",
164
164
  "device_authorization_endpoint" => "#{url}/device_authorization",
165
165
  "handshake_endpoint" => "#{url}/handshake",
166
+ "enrollment_endpoint" => "#{url}/enrollments",
166
167
  "masks_protocol_version" => Masks::PROTOCOL_VERSION,
167
168
  "revocation_endpoint" => "#{url}/revoke",
168
169
  "introspection_endpoint" => "#{url}/introspect",
@@ -416,8 +416,7 @@ module Masks
416
416
  "rid" => rid,
417
417
  "docs" => ::Rails.configuration.masks.docs_url,
418
418
  "actor" => actor && { "nickname" => actor.nickname, "name" => actor.name,
419
- "identifier" => actor.identifier },
420
- "person" => person_json,
419
+ "identifier" => actor.identifier, "card" => card_json },
421
420
  "client" => client_json,
422
421
  "tenant" => tenant && {
423
422
  "name" => tenant.name, "logo" => tenant.logo_url
@@ -429,10 +428,8 @@ module Masks
429
428
  answering.reduce(base) { |json, state| state.enabled? ? json.merge(state.as_json) : json }
430
429
  end
431
430
 
432
- def person_json
433
- return nil if actor.nil? || authenticated_at.nil?
434
-
435
- actor.person_json
431
+ def card_json
432
+ actor.card_json if authenticated_at
436
433
  end
437
434
 
438
435
  def forget_vanished_actor!
@@ -17,12 +17,15 @@ module Masks
17
17
  Scopes.list(scopes).select { |scope| Scopes.prefix?(scope) && !scope.start_with?(Scopes::NAMESPACE) }
18
18
  end
19
19
 
20
- def named(scopes)
21
- where(name: prefixes(scopes))
20
+ def overlapping(scopes)
21
+ names = prefixes(scopes)
22
+ return none if names.empty?
23
+
24
+ where(names.map { "(starts_with(name, ?) OR starts_with(?, name))" }.join(" OR "), *names.flat_map { |name| [ name, name ] })
22
25
  end
23
26
 
24
27
  def taken(handshake)
25
- named(handshake.scopes).where.not(resource: handshake.resource.to_s)
28
+ overlapping(handshake.scopes).where.not(resource: handshake.resource.to_s)
26
29
  end
27
30
 
28
31
  def refusal(handshake)
@@ -19,7 +19,8 @@ module Masks
19
19
  "redirect_uris" => handshake.redirect_uris,
20
20
  "state" => handshake.state,
21
21
  "auth_method" => handshake.auth_method,
22
- "backchannel_logout_uri" => handshake.backchannel_logout_uri
22
+ "backchannel_logout_uri" => handshake.backchannel_logout_uri,
23
+ "post_logout_redirect_uri" => handshake.post_logout_redirect_uri
23
24
  }.compact
24
25
  )
25
26
  end
@@ -1,10 +1,28 @@
1
1
  module Masks
2
2
  module Server
3
- module Replay
4
- def self.first?(kind, id, expires_in:, within: nil)
5
- key = [ kind, Current.tenant&.id, within, Digest::SHA256.hexdigest(id.to_s) ].compact.join(":")
3
+ class Replay < ApplicationRecord
4
+ include TenantScoped
6
5
 
7
- ::Rails.cache.write(key, true, expires_in: expires_in, unless_exist: true)
6
+ class << self
7
+ def first?(kind, id, expires_in:, within: nil)
8
+ now = Time.current
9
+ claimed = connection.exec_query(sanitize_sql_array([ <<~SQL, Current.tenant.id, kind.to_s, digest(within, id), now + expires_in, now, now ]), "Replay")
10
+ INSERT INTO replays (tenant_id, kind, digest, expires_at, created_at)
11
+ VALUES (?, ?, ?, ?, ?)
12
+ ON CONFLICT (tenant_id, kind, digest) DO UPDATE
13
+ SET expires_at = EXCLUDED.expires_at, created_at = EXCLUDED.created_at
14
+ WHERE replays.expires_at <= ?
15
+ RETURNING id
16
+ SQL
17
+
18
+ claimed.rows.any?
19
+ end
20
+
21
+ private
22
+
23
+ def digest(within, id)
24
+ Digest::SHA256.hexdigest([ within&.to_s, id.to_s ].to_json)
25
+ end
8
26
  end
9
27
  end
10
28
  end
@@ -23,7 +23,7 @@ module Masks
23
23
 
24
24
  class << self
25
25
  def start!(actor:, device: nil, user_agent: nil, ip_address: nil, amr: [], origin: nil, policy: nil,
26
- first_factor: {})
26
+ first_factor: {}, authenticated_at: nil)
27
27
  secret = SecureRandom.urlsafe_base64(48)
28
28
  lifetime = policy&.session_lifetime&.seconds || LIFETIME
29
29
  now = Time.current
@@ -36,7 +36,7 @@ module Masks
36
36
  user_agent: user_agent,
37
37
  ip_address: ip_address,
38
38
  origin: origin.presence || Current.origin,
39
- authenticated_at: now,
39
+ authenticated_at: authenticated_at || now,
40
40
  last_seen_at: now,
41
41
  amr: Array(amr),
42
42
  first_factor: first_factor["factor"],
@@ -71,10 +71,12 @@ module Masks
71
71
  { "factor" => first_factor, "provider_id" => provider_id }.compact
72
72
  end
73
73
 
74
- def first_factored!(held)
75
- return if held["factor"].blank? || held == first_factored_by
74
+ def first_factored!(held, at: nil)
75
+ changes = {}
76
+ changes[:authenticated_at] = at if at.present? && (authenticated_at.nil? || at > authenticated_at)
77
+ changes.merge!(first_factor: held["factor"], provider_id: held["provider_id"]) if held["factor"].present? && held != first_factored_by
76
78
 
77
- update!(first_factor: held["factor"], provider_id: held["provider_id"])
79
+ update!(changes) if changes.any?
78
80
  end
79
81
 
80
82
  def bound?
@@ -16,6 +16,7 @@ module Masks
16
16
  "password_reset" => "PasswordReset",
17
17
  "confirmation" => "ConfirmationCode",
18
18
  "initial_access" => "InitialAccessToken",
19
+ "enrollment" => "EnrollmentToken",
19
20
  "request" => "PendingRequest",
20
21
  "login" => "PendingLogin",
21
22
  "pushed" => "PushedRequest",
@@ -85,7 +85,7 @@ module Masks
85
85
  end
86
86
 
87
87
  def scopes_stay_within_the_client
88
- beyond = Scopes.refused(client.scope_list, requested_scopes)
88
+ beyond = client.refused_scopes(requested_scopes)
89
89
 
90
90
  deny!("invalid_scope", "this client may not hold #{beyond.join(', ')}") if beyond.any?
91
91
  end
@@ -8,7 +8,7 @@ module Masks
8
8
  end
9
9
 
10
10
  def scopes_are_permitted
11
- refused = Scopes.refused(client.scope_list, requested_scopes)
11
+ refused = client.refused_scopes(requested_scopes)
12
12
 
13
13
  if refused.any?
14
14
  deny!("invalid_scope",
@@ -1,11 +1,11 @@
1
1
  <!DOCTYPE html>
2
- <html lang="<%= Masks::Server::Locales.tag %>" data-theme="rose">
2
+ <html lang="<%= Masks::Server::Locales.tag %>">
3
3
  <head>
4
4
  <title><%= content_for(:title) || "Manage #{Masks::Server::Current.tenant&.name}" %></title>
5
5
  <meta charset="utf-8">
6
6
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
7
7
  <meta name="masks-root" content="<%= request.script_name %>">
8
- <meta name="color-scheme" content="dark">
8
+ <meta name="color-scheme" content="light dark">
9
9
  <link rel="icon" href="/masks-public/favicon.svg" type="image/svg+xml">
10
10
  <link rel="apple-touch-icon" href="/masks-public/icon.png">
11
11
  <%= csrf_meta_tags %>
@@ -4,7 +4,7 @@
4
4
  <div class="entries">
5
5
  <details class="entry" id="profile">
6
6
  <summary>
7
- <span class="entry-icon"><%= account_icon(:person) %></span>
7
+ <span class="entry-icon"><%= account_icon(:actor) %></span>
8
8
  <span class="entry-label"><%= account_text("profile.heading") %></span>
9
9
  <span class="entry-state"><%= actor.name.presence || actor.nickname %></span>
10
10
  <span class="entry-mark" aria-hidden="true"></span>
@@ -72,7 +72,7 @@
72
72
 
73
73
  <% if own.owner? %>
74
74
  <p class="item-meta">
75
- <%= account_text("organizations.people", count: joined) %>
75
+ <%= account_text("organizations.member_count", count: joined) %>
76
76
  · <%= account_text("organizations.owners", count: owners) %>
77
77
  <% if waiting.positive? %>
78
78
  · <%= account_text("organizations.waiting_count", count: waiting) %>
@@ -81,8 +81,8 @@
81
81
 
82
82
  <div class="items">
83
83
  <% members.each do |member| %>
84
- <% person = member.actor %>
85
- <% yourself = person.id == own.actor_id %>
84
+ <% actor = member.actor %>
85
+ <% yourself = actor.id == own.actor_id %>
86
86
  <% label = Masks::Server::Members.label(member) || account_text("organizations.invited_by_manager") %>
87
87
  <div class="item item-member">
88
88
  <span class="item-name">
@@ -111,7 +111,7 @@
111
111
  class: "control control-sm", id: "role-#{organization.key}-#{member.id}",
112
112
  disabled: yourself && alone,
113
113
  data: { autosubmit: true },
114
- aria: { label: account_text("organizations.role_of", person: label) } %>
114
+ aria: { label: account_text("organizations.role_of", name: label) } %>
115
115
  <button type="submit" class="action action-quiet action-sm" data-autosubmitted <%= "disabled" if yourself && alone %>>
116
116
  <%= account_text("organizations.set_role") %>
117
117
  </button>
@@ -0,0 +1,27 @@
1
+ <% sign_out = local_assigns.fetch(:sign_out, false) %>
2
+ <% details = actor.display_details %>
3
+
4
+ <div class="actor">
5
+ <%= avatar_tag actor, size: 44, class: "actor-avatar" %>
6
+
7
+ <div class="actor-who">
8
+ <span class="actor-name"><%= actor.display_name %></span>
9
+
10
+ <% if details.any? || actor.email_unconfirmed? %>
11
+ <span class="actor-sub">
12
+ <%= details.join(" · ") %>
13
+ <% if actor.email_unconfirmed? %>
14
+ <span class="actor-note"><%= local_assigns[:unconfirmed] || t("application.actor.unconfirmed") %></span>
15
+ <% end %>
16
+ </span>
17
+ <% end %>
18
+
19
+ <% if actor.manages? %>
20
+ <span class="actor-role"><%= t("application.actor.manager") %></span>
21
+ <% end %>
22
+ </div>
23
+
24
+ <% if sign_out %>
25
+ <%= button_to t("application.actor.sign_out"), logout_path, class: "textlink", form_class: "signout" %>
26
+ <% end %>
27
+ </div>
@@ -6,7 +6,7 @@
6
6
  <h1 class="prompt-title"><%= t(".title") %></h1>
7
7
 
8
8
  <% if current_actor %>
9
- <%= render "masks/server/application/person", actor: current_actor, sign_out: true %>
9
+ <%= render "masks/server/application/actor", actor: current_actor, sign_out: true %>
10
10
  <% end %>
11
11
 
12
12
  <p class="said"><%= @description %></p>