masks-server 0.8.0 → 0.10.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/masks/server/client_authentication.rb +4 -3
  3. data/app/controllers/concerns/masks/server/resource_token.rb +12 -3
  4. data/app/controllers/masks/server/addresses_controller.rb +6 -1
  5. data/app/controllers/masks/server/application_controller.rb +36 -6
  6. data/app/controllers/masks/server/authorize_controller.rb +24 -19
  7. data/app/controllers/masks/server/device_verifications_controller.rb +1 -1
  8. data/app/controllers/masks/server/logins_controller.rb +3 -1
  9. data/app/controllers/masks/server/notifications_controller.rb +11 -1
  10. data/app/controllers/masks/server/profiles_controller.rb +5 -0
  11. data/app/controllers/masks/server/registrations_controller.rb +9 -1
  12. data/app/controllers/masks/server/saml_controller.rb +1 -7
  13. data/app/controllers/masks/server/scim/metadata_controller.rb +1 -1
  14. data/app/controllers/masks/server/tokens_controller.rb +9 -1
  15. data/app/graphql/masks/server/manage/mutations/add_app.rb +1 -1
  16. data/app/graphql/masks/server/manage/mutations/create_actor.rb +1 -0
  17. data/app/graphql/masks/server/manage/mutations/update_actor.rb +1 -0
  18. data/app/graphql/masks/server/manage/mutations/update_client.rb +4 -0
  19. data/app/graphql/masks/server/manage/types/client_type.rb +2 -0
  20. data/app/graphql/masks/server/manage/types/query_type.rb +7 -0
  21. data/app/helpers/masks/server/account_helper.rb +1 -1
  22. data/app/jobs/masks/server/cleanup_job.rb +1 -0
  23. data/app/ldap/masks/server/ldap/listener.rb +16 -3
  24. data/app/logins/masks/server/login_states/confirmation.rb +9 -3
  25. data/app/logins/masks/server/login_states/signup.rb +1 -0
  26. data/app/models/masks/server/actor.rb +28 -5
  27. data/app/models/masks/server/client.rb +61 -6
  28. data/app/models/masks/server/client_keys.rb +12 -1
  29. data/app/models/masks/server/declared.rb +2 -1
  30. data/app/models/masks/server/event.rb +1 -0
  31. data/app/models/masks/server/handshake.rb +12 -6
  32. data/app/models/masks/server/login.rb +3 -6
  33. data/app/models/masks/server/notifications.rb +8 -0
  34. data/app/models/masks/server/pending_handshake.rb +2 -1
  35. data/app/models/masks/server/pending_request.rb +8 -0
  36. data/app/models/masks/server/refresh_token.rb +8 -0
  37. data/app/models/masks/server/released_nickname.rb +26 -0
  38. data/app/models/masks/server/scim/filter.rb +45 -5
  39. data/app/models/masks/server/scim/user.rb +1 -0
  40. data/app/models/masks/server/session.rb +6 -5
  41. data/app/policies/masks/server/exchange_policy.rb +1 -1
  42. data/app/policies/masks/server/grant_checks.rb +1 -1
  43. data/app/views/masks/server/account/_info.html.erb +13 -5
  44. data/app/views/masks/server/account/_organizations.html.erb +4 -4
  45. data/app/views/masks/server/account/index.html.erb +1 -1
  46. data/app/views/masks/server/application/_actor.html.erb +27 -0
  47. data/app/views/masks/server/authorize/error.html.erb +2 -2
  48. data/app/views/masks/server/handshakes/refused.html.erb +1 -1
  49. data/app/views/masks/server/handshakes/show.html.erb +1 -1
  50. data/app/views/masks/server/logins/_choose_organization.html.erb +1 -1
  51. data/app/views/masks/server/logins/_consent.html.erb +1 -1
  52. data/app/views/masks/server/sessions/confirm.html.erb +1 -1
  53. data/config/locales/de/account.yml +6 -2
  54. data/config/locales/de/actors.yml +2 -0
  55. data/config/locales/de/application.yml +1 -1
  56. data/config/locales/de/authorize.yml +2 -0
  57. data/config/locales/de/events.yml +7 -5
  58. data/config/locales/de/flashes.yml +1 -0
  59. data/config/locales/de/logins.yml +1 -0
  60. data/config/locales/de/manage/client.yml +3 -0
  61. data/config/locales/de/manage/groups.yml +1 -1
  62. data/config/locales/de/manage/ldap.yml +1 -0
  63. data/config/locales/de/manage/organization.yml +1 -1
  64. data/config/locales/de/notifications.yml +1 -0
  65. data/config/locales/en/account.yml +6 -2
  66. data/config/locales/en/actors.yml +2 -0
  67. data/config/locales/en/application.yml +1 -1
  68. data/config/locales/en/authorize.yml +2 -0
  69. data/config/locales/en/events.yml +7 -5
  70. data/config/locales/en/flashes.yml +1 -0
  71. data/config/locales/en/logins.yml +1 -0
  72. data/config/locales/en/manage/client.yml +3 -0
  73. data/config/locales/en/manage/groups.yml +1 -1
  74. data/config/locales/en/manage/ldap.yml +1 -0
  75. data/config/locales/en/manage/organization.yml +1 -1
  76. data/config/locales/en/notifications.yml +1 -0
  77. data/config/locales/es/account.yml +6 -2
  78. data/config/locales/es/actors.yml +2 -0
  79. data/config/locales/es/application.yml +1 -1
  80. data/config/locales/es/authorize.yml +2 -0
  81. data/config/locales/es/events.yml +7 -5
  82. data/config/locales/es/flashes.yml +1 -0
  83. data/config/locales/es/logins.yml +1 -0
  84. data/config/locales/es/manage/client.yml +3 -0
  85. data/config/locales/es/manage/groups.yml +1 -1
  86. data/config/locales/es/manage/ldap.yml +1 -0
  87. data/config/locales/es/manage/organization.yml +1 -1
  88. data/config/locales/es/notifications.yml +1 -0
  89. data/config/locales/fr/account.yml +6 -2
  90. data/config/locales/fr/actors.yml +2 -0
  91. data/config/locales/fr/application.yml +1 -1
  92. data/config/locales/fr/authorize.yml +2 -0
  93. data/config/locales/fr/events.yml +7 -5
  94. data/config/locales/fr/flashes.yml +1 -0
  95. data/config/locales/fr/logins.yml +1 -0
  96. data/config/locales/fr/manage/client.yml +3 -0
  97. data/config/locales/fr/manage/groups.yml +1 -1
  98. data/config/locales/fr/manage/ldap.yml +1 -0
  99. data/config/locales/fr/manage/organization.yml +1 -1
  100. data/config/locales/fr/notifications.yml +1 -0
  101. data/config/routes.rb +1 -0
  102. data/db/migrate/20261013010000_add_handshake_return_to_to_clients.rb +5 -0
  103. data/db/migrate/20261018100000_create_released_nicknames.rb +22 -0
  104. data/db/migrate/20261018110000_add_refresh_token_absolute_lifetime_to_clients.rb +5 -0
  105. data/lib/generators/masks/server/install/install_generator.rb +2 -2
  106. data/lib/masks/server/browser_access.rb +35 -0
  107. data/lib/masks/server/configuration.rb +11 -3
  108. data/lib/masks/server/engine.rb +1 -0
  109. data/lib/masks/server/schema_reference.rb +17 -4
  110. data/lib/masks/server/tenancy/middleware.rb +9 -1
  111. data/lib/masks/server.rb +1 -0
  112. data/public/masks-assets/.vite/manifest.json +7 -7
  113. data/public/masks-assets/assets/app-D7W0gAyT.css +2 -0
  114. data/public/masks-assets/assets/{initial-D99AzFOd.js → initial-C1GpCiEh.js} +2 -2
  115. data/public/masks-assets/assets/{initial-D99AzFOd.js.map → initial-C1GpCiEh.js.map} +1 -1
  116. data/public/masks-assets/assets/login-C_6igRZ3.js +4 -0
  117. data/public/masks-assets/assets/login-C_6igRZ3.js.map +1 -0
  118. data/public/masks-assets/assets/manage-DR9qQiBU.js +569 -0
  119. data/public/masks-assets/assets/manage-DR9qQiBU.js.map +1 -0
  120. data/version.txt +1 -1
  121. metadata +14 -9
  122. data/app/views/masks/server/application/_person.html.erb +0 -27
  123. data/public/masks-assets/assets/app-DoQZrM_J.css +0 -2
  124. data/public/masks-assets/assets/login-CNs1UpxJ.js +0 -4
  125. data/public/masks-assets/assets/login-CNs1UpxJ.js.map +0 -1
  126. data/public/masks-assets/assets/manage-isLCrWiU.js +0 -567
  127. data/public/masks-assets/assets/manage-isLCrWiU.js.map +0 -1
@@ -33,6 +33,9 @@ module Masks
33
33
  has_many :groups, through: :group_members
34
34
 
35
35
  before_destroy :sign_out_everywhere!, prepend: true
36
+ before_destroy :release_nickname
37
+
38
+ attr_accessor :trusted_rename
36
39
 
37
40
  validates :nickname,
38
41
  uniqueness: { scope: :tenant_id, case_sensitive: false },
@@ -52,8 +55,10 @@ module Masks
52
55
  allow_blank: true
53
56
 
54
57
  validate :named
58
+ validate :nickname_free, if: -> { nickname.present? && will_save_change_to_nickname? && !trusted_rename }
55
59
 
56
60
  before_save :activate_once_a_password_exists
61
+ after_save :release_previous_nickname, if: :saved_change_to_nickname?
57
62
  before_save :forget_code_factors_for_new_addresses
58
63
  after_update :forget_mailed_links, if: :saved_change_to_email?
59
64
 
@@ -190,14 +195,14 @@ module Masks
190
195
  holds?(Scopes::MANAGE)
191
196
  end
192
197
 
193
- def person_json
198
+ def card_json
194
199
  style = Avatars.held?(self) ? Avatars::PHOTO : Avatars::FALLBACK
195
200
 
196
201
  {
197
202
  "name" => display_name,
198
203
  "details" => display_details,
199
- "note" => (I18n.t("application.person.unconfirmed") if email_unconfirmed?),
200
- "role" => (I18n.t("application.person.manager") if manages?),
204
+ "note" => (I18n.t("application.actor.unconfirmed") if email_unconfirmed?),
205
+ "role" => (I18n.t("application.actor.manager") if manages?),
201
206
  "avatar" => Avatars.url(self, style, subject: uuid, size: 128)
202
207
  }
203
208
  end
@@ -282,11 +287,16 @@ module Masks
282
287
 
283
288
  held.find_each(&:revoke!)
284
289
  RefreshToken.where(actor_id: id).live.find_each(&:revoke!)
290
+
291
+ issued = AccessToken.where(actor_id: id).live
292
+ issued = issued.where.not(session_id: keeping.id) if keeping
293
+ issued.update_all(consumed_at: Time.current, updated_at: Time.current)
294
+
285
295
  DeviceFactor.forget!(actor: self)
286
296
  end
287
297
 
288
298
  def notified?(action)
289
- Notifications.mailed?(action) && !muted_notifications.include?(action.to_s)
299
+ Notifications.mailed?(action) && (Notifications.always?(action) || !muted_notifications.include?(action.to_s))
290
300
  end
291
301
 
292
302
  def notifications
@@ -294,7 +304,7 @@ module Masks
294
304
  end
295
305
 
296
306
  def notifications=(wanted)
297
- self.muted_notifications = Notifications::MAILED - Array(wanted).map(&:to_s)
307
+ self.muted_notifications = Notifications::MUTABLE - Array(wanted).map(&:to_s)
298
308
  end
299
309
 
300
310
  def otp?
@@ -508,6 +518,19 @@ module Masks
508
518
  end
509
519
  end
510
520
 
521
+ def nickname_free
522
+ errors.add(:nickname, :held) if ReleasedNickname.held_from?(nickname, self)
523
+ end
524
+
525
+ def release_previous_nickname
526
+ ReleasedNickname.where(nickname: nickname.to_s.downcase, actor_id: id).delete_all if nickname.present?
527
+ ReleasedNickname.release!(nickname_before_last_save, actor: self)
528
+ end
529
+
530
+ def release_nickname
531
+ ReleasedNickname.release!(nickname, actor: self)
532
+ end
533
+
511
534
  def manages_by_both
512
535
  errors.add(:nickname, :blank) if nickname.blank?
513
536
  errors.add(:email, :blank) if email.blank?
@@ -82,6 +82,8 @@ module Masks
82
82
  validate :resources_are_named_only_when_approved
83
83
  validates :access_token_lifetime, numericality: { only_integer: true, in: 60..1.day.to_i }, allow_nil: true
84
84
  validates :refresh_token_lifetime, numericality: { only_integer: true, in: 5.minutes.to_i..400.days.to_i }, allow_nil: true
85
+ validates :refresh_token_absolute_lifetime, numericality: { only_integer: true, in: 5.minutes.to_i..400.days.to_i },
86
+ allow_nil: true
85
87
  validates :consent_lifetime, numericality: { only_integer: true, greater_than_or_equal_to: 300,
86
88
  less_than_or_equal_to: Consent::LONGEST.to_i }, allow_nil: true
87
89
 
@@ -123,7 +125,7 @@ module Masks
123
125
  {
124
126
  name: handshake.name,
125
127
  redirect_uris: handshake.redirect_uris,
126
- post_logout_redirect_uris: [ handshake.return_to ].compact,
128
+ post_logout_redirect_uris: [ handshake.post_logout_redirect_uri ].compact,
127
129
  resources: [ handshake.resource ],
128
130
  allowed_scopes: Scopes.join(handshake.scopes),
129
131
  grant_types: handshake.grant_types,
@@ -131,6 +133,7 @@ module Masks
131
133
  token_endpoint_auth_method: handshake.auth_method,
132
134
  backchannel_logout_uri: handshake.backchannel_logout_uri,
133
135
  client_uri: handshake.origin,
136
+ handshake_return_to: handshake.return_to,
134
137
  dynamic: false
135
138
  }
136
139
  end
@@ -169,8 +172,7 @@ module Masks
169
172
  name: held[:name] || name,
170
173
  grant_types: grant_types,
171
174
  response_types: held[:response_types].presence || response_types_for(grant_types),
172
- allowed_scopes: Scopes.join(bounded(metadata.to_h.stringify_keys["scope"].presence ||
173
- Current.tenant&.dynamic_client_ceiling || DEFAULT_SCOPES)),
175
+ allowed_scopes: Scopes.join(declared_scopes(metadata)),
174
176
  dynamic: true
175
177
  )
176
178
  end
@@ -206,6 +208,12 @@ module Masks
206
208
  ).issue_credentials!
207
209
  end
208
210
 
211
+ def declared_scopes(metadata)
212
+ declared = metadata.to_h.stringify_keys["scope"]
213
+
214
+ Scopes.list(declared).empty? ? [] : bounded(declared)
215
+ end
216
+
209
217
  def bounded(requested)
210
218
  reserved = Scopes.reserved(requested) + Scopes.list(requested).select { |s| Scopes.prefix?(s) }
211
219
 
@@ -371,6 +379,11 @@ module Masks
371
379
 
372
380
  def authenticate_secret(candidate)
373
381
  return true if public?
382
+
383
+ secret_matches?(candidate) && !secret_expired?
384
+ end
385
+
386
+ def secret_matches?(candidate)
374
387
  return false unless secret?
375
388
  return false if secret_digest.blank? || candidate.blank?
376
389
 
@@ -379,8 +392,12 @@ module Masks
379
392
  false
380
393
  end
381
394
 
395
+ def secret_expired?
396
+ secret_expires_at.present? && secret_expires_at.past?
397
+ end
398
+
382
399
  def redirect_uri?(candidate)
383
- redirect_uris.include?(candidate.to_s)
400
+ redirect_uris.include?(candidate.to_s) || redirect_uris.any? { |registered| same_loopback?(registered, candidate.to_s) }
384
401
  end
385
402
 
386
403
  def grants?(grant_type)
@@ -424,7 +441,11 @@ module Masks
424
441
  end
425
442
 
426
443
  def scope_list
427
- Scopes.union(required_scopes, allowed_scopes)
444
+ Scopes.union(required_scopes, offered_scopes)
445
+ end
446
+
447
+ def follows_registration?
448
+ dynamic? && Scopes.list(allowed_scopes).empty?
428
449
  end
429
450
 
430
451
  def access_lifetime
@@ -435,6 +456,10 @@ module Masks
435
456
  refresh_token_lifetime&.seconds || RefreshToken.lifetime
436
457
  end
437
458
 
459
+ def refresh_absolute_lifetime
460
+ refresh_token_absolute_lifetime&.seconds || RefreshToken.absolute_lifetime
461
+ end
462
+
438
463
  def admits?(actor)
439
464
  allowed_groups.empty? || actor.group_labels.intersect?(allowed_groups)
440
465
  end
@@ -442,7 +467,19 @@ module Masks
442
467
  def permitted_scopes(requested)
443
468
  return scope_list if Scopes.list(requested).empty?
444
469
 
445
- Scopes.union(required_scopes, Scopes.granted(requested, allowed_scopes))
470
+ return Scopes.union(required_scopes, unreserved(requested)) if open_to_anything?
471
+
472
+ Scopes.union(required_scopes, Scopes.granted(requested, offered_scopes))
473
+ end
474
+
475
+ def refused_scopes(requested)
476
+ Scopes.list(requested) - permitted_scopes(requested)
477
+ end
478
+
479
+ def offered_scopes
480
+ return Scopes.list(allowed_scopes) unless follows_registration?
481
+
482
+ Current.tenant&.dynamic_client_ceiling || DEFAULT_SCOPES
446
483
  end
447
484
 
448
485
  def metadata
@@ -544,6 +581,24 @@ module Masks
544
581
  self.class.loopback?(uri)
545
582
  end
546
583
 
584
+ def open_to_anything?
585
+ follows_registration? && Current.tenant&.dynamic_registration == Tenant::REGISTRATION_ANYTHING
586
+ end
587
+
588
+ def unreserved(requested)
589
+ Scopes.list(requested).reject { |scope| Scopes.prefix?(scope) || Scopes.reserved(scope).any? }
590
+ end
591
+
592
+ def same_loopback?(registered, candidate)
593
+ held = URI.parse(registered)
594
+ asked = URI.parse(candidate)
595
+
596
+ [ held, asked ].all? { |uri| uri.instance_of?(URI::HTTP) && LOOPBACK.include?(uri.hostname) && uri.userinfo.nil? && uri.fragment.nil? } &&
597
+ held.hostname == asked.hostname && held.path == asked.path && held.query == asked.query
598
+ rescue URI::InvalidURIError
599
+ false
600
+ end
601
+
547
602
  def grant_types_are_known
548
603
  unknown = grant_types - GRANT_TYPES
549
604
  errors.add(:grant_types, "not supported: #{unknown.join(', ')}") if unknown.any?
@@ -78,7 +78,7 @@ module Masks
78
78
  raise Refused, "#{algorithm.presence || 'that'} is not an algorithm #{subject} may be signed with" unless ALGORITHMS.include?(algorithm)
79
79
 
80
80
  verified = attempt(token, header, algorithm, fresh: false)
81
- verified ||= attempt(token, header, algorithm, fresh: true) if remote? && unknown_kid?(header)
81
+ verified ||= attempt(token, header, algorithm, fresh: true) if remote? && unknown_kid?(header) && refetch_open?
82
82
 
83
83
  verified || raise(Refused, "#{subject} was not signed by a key this client registered")
84
84
  rescue JWT::DecodeError
@@ -127,9 +127,20 @@ module Masks
127
127
  kid ? held.select { |jwk| jwk["kid"] == kid } : held
128
128
  end
129
129
 
130
+ def refetch_open?
131
+ return false if ::Rails.cache.exist?(refetched_key)
132
+
133
+ ::Rails.cache.write(refetched_key, Time.current.to_i, expires_in: LIFETIME)
134
+ true
135
+ end
136
+
130
137
  def cache_key
131
138
  "client-jwks:#{client.tenant_id}:#{client.id}:#{Digest::SHA256.hexdigest(client.jwks_uri.to_s)}"
132
139
  end
140
+
141
+ def refetched_key
142
+ "#{cache_key}:refetched"
143
+ end
133
144
  end
134
145
  end
135
146
  end
@@ -5,7 +5,7 @@ module Masks
5
5
 
6
6
  TENANT_KEYS = %w[name accent_color groups apps].freeze
7
7
  APP_KEYS = %w[preset url slug name client_id client_secret_env redirect_uris scopes allowed_groups
8
- access_token_lifetime refresh_token_lifetime].freeze
8
+ access_token_lifetime refresh_token_lifetime refresh_token_absolute_lifetime].freeze
9
9
 
10
10
  def self.apply!(path = ::Rails.configuration.masks.config_path)
11
11
  return [] if path.blank?
@@ -92,6 +92,7 @@ module Masks
92
92
  allowed_groups: Array(declared["allowed_groups"]),
93
93
  access_token_lifetime: declared["access_token_lifetime"],
94
94
  refresh_token_lifetime: declared["refresh_token_lifetime"],
95
+ refresh_token_absolute_lifetime: declared["refresh_token_absolute_lifetime"],
95
96
  consent_required: false
96
97
  }
97
98
 
@@ -18,6 +18,7 @@ module Masks
18
18
 
19
19
  ACCOUNT_CREATED = "account.created".freeze
20
20
  ACCOUNT_EXPORTED = "account.exported".freeze
21
+ NOTIFICATIONS_CHANGED = "notifications.changed".freeze
21
22
  INVITATION_SENT = "invitation.sent".freeze
22
23
  INVITATION_ACCEPTED = "invitation.accepted".freeze
23
24
 
@@ -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
 
@@ -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!
@@ -51,6 +51,10 @@ module Masks
51
51
 
52
52
  MAILED = GROUPS.values.flatten.freeze
53
53
 
54
+ ALWAYS = [ Event::PASSWORD_CHANGED, Event::PASSWORD_RESET_COMPLETED, Event::SIGN_IN_RISKY ].freeze
55
+
56
+ MUTABLE = (MAILED - ALWAYS).freeze
57
+
54
58
  ONCE_PER_DEVICE = [ Event::SESSION_STARTED ].freeze
55
59
 
56
60
  class << self
@@ -58,6 +62,10 @@ module Masks
58
62
  MAILED.include?(action.to_s)
59
63
  end
60
64
 
65
+ def always?(action)
66
+ ALWAYS.include?(action.to_s)
67
+ end
68
+
61
69
  def raised(event)
62
70
  return false unless mailed?(event.action) || WARNS_PREVIOUS.include?(event.action)
63
71
  return false unless event.actor_id && ActorMailer.deliverable?
@@ -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
@@ -39,6 +39,14 @@ module Masks
39
39
  held("max_age")
40
40
  end
41
41
 
42
+ def refuse!(error, description)
43
+ update!(payload: (payload || {}).merge("refusal" => { "error" => error, "description" => description }))
44
+ end
45
+
46
+ def refusal
47
+ held("refusal")
48
+ end
49
+
42
50
  def issue_code!(actor:, device: nil, session: nil, authenticated_at: nil, amr: nil, organization: nil)
43
51
  AuthorizationCode.mint!(
44
52
  organization: organization,
@@ -5,6 +5,14 @@ module Masks
5
5
  30.days
6
6
  end
7
7
 
8
+ def self.absolute_lifetime
9
+ 90.days
10
+ end
11
+
12
+ def exhausted?(limit)
13
+ root.created_at + limit <= Time.current
14
+ end
15
+
8
16
  def self.mint!(**attributes)
9
17
  super.tap { |token| token.actor&.active! }
10
18
  end
@@ -0,0 +1,26 @@
1
+ module Masks
2
+ module Server
3
+ class ReleasedNickname < ApplicationRecord
4
+ include TenantScoped
5
+
6
+ HOLD = 30.days
7
+
8
+ belongs_to :actor, optional: true
9
+
10
+ scope :holding, ->(nickname) { where(nickname: nickname.to_s.downcase).where("released_at > ?", HOLD.ago) }
11
+ scope :lapsed, -> { where(released_at: ...HOLD.ago) }
12
+
13
+ class << self
14
+ def release!(nickname, actor:)
15
+ return if nickname.blank?
16
+
17
+ find_or_initialize_by(nickname: nickname.downcase).update!(actor: actor, released_at: Time.current)
18
+ end
19
+
20
+ def held_from?(nickname, actor)
21
+ holding(nickname).where.not(actor_id: actor.id).or(holding(nickname).where(actor_id: nil)).exists?
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,8 +1,14 @@
1
+ require "strscan"
2
+
1
3
  module Masks
2
4
  module Server
3
5
  module Scim
4
6
  class Filter
5
- COMPARISON = /\A\s*([A-Za-z][\w.:\[\]" -]*?)\s+(eq|ne|co|sw|ew|pr|gt|ge|lt|le)(?:\s+("(?:[^"\\]|\\.)*"|true|false|null|[\d.]+))?\s*\z/i
7
+ LONGEST = 512
8
+ TOKEN = /"(?:[^"\\]|\\.)*"|(?:[^\s"\[]|\[[^\]]*\])+/
9
+ PATH = /\A[A-Za-z][\w.:\[\]" ]*\z/
10
+ VALUE = /\A(?:"(?:[^"\\]|\\.)*"|true|false|null|[\d.]+)\z/
11
+ OPERATORS = %w[eq ne co sw ew pr gt ge lt le].freeze
6
12
  ATTRIBUTES = {
7
13
  "id" => "actors.uuid",
8
14
  "username" => :user_name,
@@ -30,7 +36,9 @@ module Masks
30
36
  end
31
37
 
32
38
  def apply(relation)
33
- clauses = @expression.split(/\s+and\s+/i)
39
+ raise Error.new(:bad_request, "a filter is at most #{LONGEST} characters", scim_type: "invalidFilter") if @expression.length > LONGEST
40
+
41
+ clauses = clauses_of(tokens)
34
42
 
35
43
  raise Error.new(:bad_request, "at most four comparisons joined by and are understood", scim_type: "invalidFilter") if clauses.size > 4
36
44
 
@@ -39,12 +47,44 @@ module Masks
39
47
 
40
48
  private
41
49
 
50
+ def tokens
51
+ scanner = StringScanner.new(@expression)
52
+ held = []
53
+
54
+ until scanner.eos?
55
+ next if scanner.skip(/\s+/)
56
+
57
+ token = scanner.scan(TOKEN)
58
+
59
+ raise unreadable(@expression) if token.nil?
60
+
61
+ held << token
62
+ end
63
+
64
+ held
65
+ end
66
+
67
+ def clauses_of(tokens)
68
+ clauses = [ [] ]
69
+
70
+ tokens.each do |token|
71
+ token.casecmp?("and") ? clauses << [] : clauses.last << token
72
+ end
73
+
74
+ clauses
75
+ end
76
+
77
+ def unreadable(clause)
78
+ Error.new(:bad_request, "#{clause.strip} is not a filter this server reads", scim_type: "invalidFilter")
79
+ end
80
+
42
81
  def compare(relation, clause)
43
- match = COMPARISON.match(clause)
82
+ path, operator, raw = clause
44
83
 
45
- raise Error.new(:bad_request, "#{clause.strip} is not a filter this server reads", scim_type: "invalidFilter") if match.nil?
84
+ raise unreadable(clause.join(" ")) unless clause.size.between?(2, 3)
85
+ raise unreadable(clause.join(" ")) unless PATH.match?(path) && OPERATORS.include?(operator.downcase)
86
+ raise unreadable(clause.join(" ")) unless raw.nil? || VALUE.match?(raw)
46
87
 
47
- path, operator, raw = match.captures
48
88
  key = normalized(path)
49
89
  column = @columns[key]
50
90
  operator = operator.downcase
@@ -118,6 +118,7 @@ module Masks
118
118
  raise Error.new(:bad_request, "userName is required", scim_type: "invalidValue") if value.blank?
119
119
 
120
120
  held = value.to_s.strip
121
+ actor.trusted_rename = true
121
122
 
122
123
  if held.match?(URI::MailTo::EMAIL_REGEXP)
123
124
  actor.email = held
@@ -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,11 @@ 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
+ self.authenticated_at = at if at && at > authenticated_at
76
+ assign_attributes(first_factor: held["factor"], provider_id: held["provider_id"]) if held["factor"].present?
76
77
 
77
- update!(first_factor: held["factor"], provider_id: held["provider_id"])
78
+ save! if changed?
78
79
  end
79
80
 
80
81
  def bound?
@@ -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",
@@ -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>
@@ -46,7 +46,11 @@
46
46
  </summary>
47
47
 
48
48
  <div class="entry-body">
49
- <p class="aside"><%= account_text("notifications.lede") %></p>
49
+ <p class="aside"><%= account_text("notifications.lede") %> <%= account_text("notifications.kept") %></p>
50
+
51
+ <% unless fresh %>
52
+ <p class="aside"><%= account_text("notifications.again") %></p>
53
+ <% end %>
50
54
 
51
55
  <% unless Masks::Server::Notifications.mailable?(actor) %>
52
56
  <p class="aside aside-bad"><%= account_text("notifications.silent") %></p>
@@ -63,8 +67,12 @@
63
67
  <% actions.each do |action| %>
64
68
  <label class="switch">
65
69
  <span><%= t("events.actions.#{action}") %></span>
66
- <%= check_box_tag "notifications[]", action, actor.notified?(action),
67
- id: "notify-#{action.tr('.', '-')}" %>
70
+ <% if Masks::Server::Notifications.always?(action) %>
71
+ <span class="switch-kept"><%= account_text("notifications.always") %></span>
72
+ <% else %>
73
+ <%= check_box_tag "notifications[]", action, actor.notified?(action),
74
+ id: "notify-#{action.tr('.', '-')}" %>
75
+ <% end %>
68
76
  </label>
69
77
  <% end %>
70
78
  </div>
@@ -72,7 +80,7 @@
72
80
  <% end %>
73
81
 
74
82
  <div class="action-row">
75
- <button type="submit" class="action action-sm"><%= account_text("notifications.submit") %></button>
83
+ <button type="submit" class="action action-sm"><%= account_text(fresh ? "notifications.submit" : "notifications.submit_again") %></button>
76
84
  </div>
77
85
  <% end %>
78
86
  </div>