masks-server 0.9.0 → 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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/masks/server/client_authentication.rb +3 -2
  3. data/app/controllers/masks/server/addresses_controller.rb +6 -1
  4. data/app/controllers/masks/server/application_controller.rb +29 -1
  5. data/app/controllers/masks/server/authorize_controller.rb +23 -18
  6. data/app/controllers/masks/server/device_verifications_controller.rb +1 -1
  7. data/app/controllers/masks/server/discovery_controller.rb +36 -0
  8. data/app/controllers/masks/server/enrollments_controller.rb +17 -0
  9. data/app/controllers/masks/server/logins_controller.rb +2 -1
  10. data/app/controllers/masks/server/notifications_controller.rb +11 -1
  11. data/app/controllers/masks/server/organization_members_controller.rb +3 -1
  12. data/app/controllers/masks/server/profiles_controller.rb +5 -0
  13. data/app/controllers/masks/server/saml_controller.rb +1 -7
  14. data/app/controllers/masks/server/tokens_controller.rb +8 -0
  15. data/app/graphql/masks/server/manage/mutations/create_actor.rb +1 -0
  16. data/app/graphql/masks/server/manage/mutations/unenroll_client.rb +28 -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 +8 -0
  20. data/app/graphql/masks/server/manage/types/mutation_type.rb +1 -0
  21. data/app/graphql/masks/server/manage/types/query_type.rb +7 -0
  22. data/app/jobs/masks/server/application_job.rb +28 -0
  23. data/app/jobs/masks/server/check_domain_claims_job.rb +1 -3
  24. data/app/jobs/masks/server/cleanup_job.rb +5 -6
  25. data/app/jobs/masks/server/idle_accounts_job.rb +1 -3
  26. data/app/jobs/masks/server/prune_queue_job.rb +19 -0
  27. data/app/jobs/masks/server/refresh_saml_metadata_job.rb +1 -3
  28. data/app/jobs/masks/server/rotate_signing_keys_job.rb +1 -3
  29. data/app/jobs/masks/server/text_job.rb +12 -1
  30. data/app/ldap/masks/server/ldap/listener.rb +16 -3
  31. data/app/logins/masks/server/login_states/confirmation.rb +9 -3
  32. data/app/logins/masks/server/login_states/signup.rb +1 -0
  33. data/app/models/masks/server/actor.rb +25 -2
  34. data/app/models/masks/server/app_preset.rb +102 -0
  35. data/app/models/masks/server/boot_task.rb +41 -0
  36. data/app/models/masks/server/client.rb +15 -0
  37. data/app/models/masks/server/client_keys.rb +12 -1
  38. data/app/models/masks/server/declared.rb +2 -1
  39. data/app/models/masks/server/enrollment_token.rb +1 -1
  40. data/app/models/masks/server/event.rb +6 -1
  41. data/app/models/masks/server/job_failures.rb +25 -0
  42. data/app/models/masks/server/notifications.rb +8 -0
  43. data/app/models/masks/server/pending_request.rb +8 -0
  44. data/app/models/masks/server/refresh_token.rb +8 -0
  45. data/app/models/masks/server/released_nickname.rb +26 -0
  46. data/app/models/masks/server/scim/filter.rb +45 -5
  47. data/app/models/masks/server/scim/user.rb +1 -0
  48. data/app/models/masks/server/session.rb +3 -4
  49. data/app/views/masks/server/account/_info.html.erb +12 -4
  50. data/app/views/masks/server/account/index.html.erb +1 -1
  51. data/app/views/masks/server/authorize/error.html.erb +2 -2
  52. data/config/locales/de/account.yml +4 -0
  53. data/config/locales/de/actors.yml +2 -0
  54. data/config/locales/de/authorize.yml +2 -0
  55. data/config/locales/de/events.yml +6 -0
  56. data/config/locales/de/flashes.yml +1 -0
  57. data/config/locales/de/logins.yml +1 -0
  58. data/config/locales/de/manage/client.yml +4 -1
  59. data/config/locales/de/manage/ldap.yml +1 -0
  60. data/config/locales/de/manage/machines.yml +13 -0
  61. data/config/locales/de/manage/namespaces.yml +1 -1
  62. data/config/locales/de/notifications.yml +1 -0
  63. data/config/locales/en/account.yml +4 -0
  64. data/config/locales/en/actors.yml +2 -0
  65. data/config/locales/en/authorize.yml +2 -0
  66. data/config/locales/en/events.yml +6 -0
  67. data/config/locales/en/flashes.yml +1 -0
  68. data/config/locales/en/logins.yml +1 -0
  69. data/config/locales/en/manage/client.yml +3 -0
  70. data/config/locales/en/manage/ldap.yml +1 -0
  71. data/config/locales/en/manage/machines.yml +13 -0
  72. data/config/locales/en/notifications.yml +1 -0
  73. data/config/locales/es/account.yml +4 -0
  74. data/config/locales/es/actors.yml +2 -0
  75. data/config/locales/es/authorize.yml +2 -0
  76. data/config/locales/es/events.yml +6 -0
  77. data/config/locales/es/flashes.yml +1 -0
  78. data/config/locales/es/handshakes.yml +1 -1
  79. data/config/locales/es/logins.yml +1 -0
  80. data/config/locales/es/manage/client.yml +3 -0
  81. data/config/locales/es/manage/ldap.yml +1 -0
  82. data/config/locales/es/manage/machines.yml +13 -0
  83. data/config/locales/es/notifications.yml +1 -0
  84. data/config/locales/fr/account.yml +4 -0
  85. data/config/locales/fr/actors.yml +2 -0
  86. data/config/locales/fr/authorize.yml +2 -0
  87. data/config/locales/fr/events.yml +6 -0
  88. data/config/locales/fr/flashes.yml +1 -0
  89. data/config/locales/fr/handshakes.yml +1 -1
  90. data/config/locales/fr/logins.yml +1 -0
  91. data/config/locales/fr/manage/client.yml +4 -1
  92. data/config/locales/fr/manage/clients.yml +1 -1
  93. data/config/locales/fr/manage/ldap.yml +1 -0
  94. data/config/locales/fr/manage/machines.yml +13 -0
  95. data/config/locales/fr/manage/providers.yml +1 -1
  96. data/config/locales/fr/notifications.yml +1 -0
  97. data/config/routes.rb +2 -0
  98. data/db/migrate/20261018100000_create_released_nicknames.rb +22 -0
  99. data/db/migrate/20261018110000_add_refresh_token_absolute_lifetime_to_clients.rb +5 -0
  100. data/db/migrate/20261019140000_add_indexes_for_cleanup_and_metrics.rb +10 -0
  101. data/lib/masks/server/configuration.rb +12 -0
  102. data/lib/masks/server/tenancy/middleware.rb +9 -1
  103. data/lib/tasks/masks.rake +31 -7
  104. data/public/masks-assets/.vite/manifest.json +2 -2
  105. data/public/masks-assets/assets/{app-B27oPUll.css → app-D7W0gAyT.css} +1 -1
  106. data/public/masks-assets/assets/manage-D4MzaDvM.js +576 -0
  107. data/public/masks-assets/assets/manage-D4MzaDvM.js.map +1 -0
  108. data/version.txt +1 -1
  109. metadata +16 -4
  110. data/public/masks-assets/assets/manage-Dbfiy3Mj.js +0 -567
  111. data/public/masks-assets/assets/manage-Dbfiy3Mj.js.map +0 -1
@@ -3,7 +3,11 @@ module Masks
3
3
  class TextJob < ApplicationJob
4
4
  queue_as :mailers
5
5
 
6
- retry_on Sender::Failed, wait: :polynomially_longer, attempts: 3
6
+ ATTEMPTS = 3
7
+
8
+ retry_on Sender::Failed, wait: :polynomially_longer, attempts: ATTEMPTS do |job, error|
9
+ job.gave_up!(error)
10
+ end
7
11
 
8
12
  def perform(to, body)
9
13
  sender = Current.tenant&.sms_sender
@@ -12,6 +16,13 @@ module Masks
12
16
 
13
17
  sender.deliver(to: to, body: body)
14
18
  end
19
+
20
+ def gave_up!(error)
21
+ Tenant.switch(held_tenant) do
22
+ Event.record!(Event::TEXT_UNDELIVERED, actor: nil, by: nil, device: nil, ip_address: nil, user_agent: nil,
23
+ said: error.message)
24
+ end
25
+ end
15
26
  end
16
27
  end
17
28
  end
@@ -6,11 +6,24 @@ module Masks
6
6
  IDLE = 5.minutes
7
7
  HANDSHAKE = 10.seconds
8
8
 
9
- attr_reader :port
9
+ EVERY_ADDRESS = "0.0.0.0".freeze
10
+ THIS_MACHINE = "127.0.0.1".freeze
11
+
12
+ attr_reader :host, :port
10
13
 
11
14
  class << self
12
15
  def from_configuration(config = ::Rails.configuration.masks)
13
- new(host: "0.0.0.0", port: config.ldap_port, tls: tls(config.ldap_cert, config.ldap_key))
16
+ tls = tls(config.ldap_cert, config.ldap_key)
17
+
18
+ new(host: host_for(tls: tls, plaintext: config.ldap_plaintext), port: config.ldap_port, tls: tls)
19
+ end
20
+
21
+ def host_for(tls:, plaintext:)
22
+ tls || plaintext ? EVERY_ADDRESS : THIS_MACHINE
23
+ end
24
+
25
+ def local_only?(config = ::Rails.configuration.masks)
26
+ config.ldap_cert.blank? && !config.ldap_plaintext
14
27
  end
15
28
 
16
29
  def tls(cert, key)
@@ -40,7 +53,7 @@ module Masks
40
53
  def start
41
54
  @server = TCPServer.new(@host, @wanted_port)
42
55
  @port = @server.local_address.ip_port
43
- @logger.info("masks ldap: listening on #{@host}:#{@port}#{' with TLS' if @tls}")
56
+ @logger.info("masks ldap: listening on #{@host}:#{@port}#{' with TLS' if @tls}#{' on this machine only' if @host == THIS_MACHINE}")
44
57
  @acceptor = Thread.new { accept_loop }
45
58
  self
46
59
  end
@@ -23,11 +23,11 @@ module Masks
23
23
  resend
24
24
  end
25
25
 
26
- handles "confirm:add-phone" do
26
+ handles "confirm:add-phone", limit: :sending do
27
27
  add_phone
28
28
  end
29
29
 
30
- handles "confirm:change-phone" do
30
+ handles "confirm:change-phone", limit: :sending do
31
31
  change_phone
32
32
  end
33
33
 
@@ -129,9 +129,15 @@ module Masks
129
129
  end
130
130
 
131
131
  def send_code(channel)
132
+ @tokens&.delete(channel)
133
+
134
+ if ConfirmationCode.crowded?(actor: actor, channel: channel)
135
+ login.store[HELD] = held.merge("#{channel}_sent" => Time.current.to_i)
136
+ return warn!(channel == ConfirmationCode::PHONE ? "too-many-texts" : "too-many-codes")
137
+ end
138
+
132
139
  token = Confirmations.send_code(actor, channel, journey: Masks::Server::Journey.sign_in(login))
133
140
 
134
- @tokens&.delete(channel)
135
141
  login.store[HELD] = held.merge(channel => token.id, "#{channel}_sent" => Time.current.to_i)
136
142
  end
137
143
 
@@ -280,6 +280,7 @@ module Masks
280
280
 
281
281
  now = Time.current
282
282
  actor = Masks::Server::Actor.new(
283
+ trusted_rename: @first_run,
283
284
  nickname: held["nickname"],
284
285
  email: held["email"],
285
286
  name: held["name"],
@@ -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
 
@@ -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?
@@ -182,6 +182,108 @@ module Masks
182
182
  [ "OAUTH_PROVIDER_NAME", "{tenant}" ], [ "ENABLE_OAUTH_SIGNUP", "true" ],
183
183
  [ "ENABLE_OAUTH_ROLE_MANAGEMENT", "true" ], [ "OAUTH_ROLES_CLAIM", "groups" ], [ "OAUTH_ADMIN_ROLES", "admins" ]
184
184
  ]
185
+ },
186
+ {
187
+ key: "tailscale", name: "Tailscale",
188
+ redirects: [ "https://login.tailscale.com/a/oauth_response" ],
189
+ scopes: %w[openid profile email],
190
+ guide: "https://tailscale.com/kb/1240/sso-custom-oidc",
191
+ settings: [
192
+ [ "WebFinger domain", "{masks_host}" ], [ "Issuer URL", "{issuer}" ],
193
+ [ "Client ID", "{client_id}" ], [ "Client secret", "{client_secret}" ]
194
+ ]
195
+ },
196
+ {
197
+ key: "headscale", name: "Headscale",
198
+ redirects: [ "{base}/oidc/callback" ],
199
+ scopes: %w[openid profile email groups],
200
+ guide: "https://headscale.net/stable/ref/oidc/",
201
+ settings: [
202
+ [ "oidc.issuer", "{issuer}" ], [ "oidc.client_id", "{client_id}" ], [ "oidc.client_secret", "{client_secret}" ],
203
+ [ "oidc.scope", "[openid, profile, email, groups]" ], [ "oidc.allowed_groups", "[admins]" ]
204
+ ]
205
+ },
206
+ {
207
+ key: "home-assistant", name: "Home Assistant (OIDC integration)",
208
+ redirects: [ "{base}/auth/oidc/callback" ],
209
+ scopes: %w[openid profile email groups],
210
+ guide: "https://github.com/christiaangoossens/hass-oidc-auth",
211
+ settings: [
212
+ [ "auth_oidc.discovery_url", "{discovery}" ], [ "auth_oidc.client_id", "{client_id}" ],
213
+ [ "auth_oidc.client_secret", "{client_secret}" ], [ "auth_oidc.display_name", "{tenant}" ],
214
+ [ "auth_oidc.roles.admin", "admins" ]
215
+ ]
216
+ },
217
+ {
218
+ key: "synapse", name: "Matrix (Synapse)", asks: { "name" => "masks" },
219
+ redirects: [ "{base}/_synapse/client/oidc/callback" ],
220
+ scopes: %w[openid profile email],
221
+ guide: "https://element-hq.github.io/synapse/latest/openid.html",
222
+ settings: [
223
+ [ "oidc_providers", "- idp_id: {name}\n idp_name: {tenant}\n issuer: {issuer}\n client_id: {client_id}\n" \
224
+ " client_secret: {client_secret}\n scopes: [openid, profile, email]\n" \
225
+ " user_mapping_provider:\n config:\n localpart_template: \"{{ user.preferred_username }}\"\n" \
226
+ " display_name_template: \"{{ user.name }}\"\n email_template: \"{{ user.email }}\"" ]
227
+ ]
228
+ },
229
+ {
230
+ key: "bookstack", name: "BookStack",
231
+ redirects: [ "{base}/oidc/callback" ],
232
+ scopes: %w[openid profile email groups],
233
+ guide: "https://www.bookstackapp.com/docs/admin/oidc-auth/",
234
+ settings: [
235
+ [ "AUTH_METHOD", "oidc" ], [ "OIDC_NAME", "{tenant}" ], [ "OIDC_ISSUER", "{issuer}" ], [ "OIDC_ISSUER_DISCOVER", "true" ],
236
+ [ "OIDC_CLIENT_ID", "{client_id}" ], [ "OIDC_CLIENT_SECRET", "{client_secret}" ],
237
+ [ "OIDC_DISPLAY_NAME_CLAIMS", "name" ], [ "OIDC_ADDITIONAL_SCOPES", "groups" ],
238
+ [ "OIDC_USER_TO_GROUPS", "true" ], [ "OIDC_GROUPS_CLAIM", "groups" ], [ "OIDC_REMOVE_FROM_GROUPS", "true" ]
239
+ ]
240
+ },
241
+ {
242
+ key: "wikijs", name: "Wiki.js", asks: { "name" => "masks" },
243
+ redirects: [ "{base}/login/{name}/callback" ],
244
+ scopes: %w[openid profile email groups],
245
+ guide: "https://docs.requarks.io/auth/oidc",
246
+ settings: [
247
+ [ "Client ID", "{client_id}" ], [ "Client Secret", "{client_secret}" ],
248
+ [ "Authorization Endpoint URL", "{authorize}" ], [ "Token Endpoint URL", "{token}" ],
249
+ [ "User Info Endpoint URL", "{userinfo}" ], [ "Issuer", "{issuer}" ], [ "Logout URL", "{logout}" ],
250
+ [ "Email Claim", "email" ], [ "Display Name Claim", "name" ], [ "Groups Claim", "groups" ],
251
+ [ "Callback URL", "{base}/login/{name}/callback" ]
252
+ ]
253
+ },
254
+ {
255
+ key: "minio", name: "MinIO",
256
+ redirects: [ "{base}/oauth_callback" ],
257
+ scopes: %w[openid profile email groups],
258
+ guide: "https://min.io/docs/minio/linux/operations/external-iam/configure-openid-external-identity-management.html",
259
+ settings: [
260
+ [ "MINIO_IDENTITY_OPENID_CONFIG_URL", "{discovery}" ], [ "MINIO_IDENTITY_OPENID_CLIENT_ID", "{client_id}" ],
261
+ [ "MINIO_IDENTITY_OPENID_CLIENT_SECRET", "{client_secret}" ], [ "MINIO_IDENTITY_OPENID_SCOPES", "openid,profile,email,groups" ],
262
+ [ "MINIO_IDENTITY_OPENID_CLAIM_NAME", "groups" ], [ "MINIO_IDENTITY_OPENID_REDIRECT_URI", "{base}/oauth_callback" ],
263
+ [ "MINIO_IDENTITY_OPENID_DISPLAY_NAME", "{tenant}" ]
264
+ ]
265
+ },
266
+ {
267
+ key: "argocd", name: "Argo CD",
268
+ redirects: [ "{base}/auth/callback", "{base}/api/dex/callback" ],
269
+ scopes: %w[openid profile email groups],
270
+ guide: "https://argo-cd.readthedocs.io/en/stable/operator-manual/user-management/#existing-oidc-provider",
271
+ settings: [
272
+ [ "argocd-cm oidc.config", "name: {tenant}\nissuer: {issuer}\nclientID: {client_id}\nclientSecret: {client_secret}\n" \
273
+ "requestedScopes: [openid, profile, email, groups]" ],
274
+ [ "argocd-rbac-cm policy.csv", "g, admins, role:admin" ], [ "argocd-rbac-cm scopes", "[groups]" ]
275
+ ]
276
+ },
277
+ {
278
+ key: "synology", name: "Synology DSM",
279
+ redirects: [ "{base}", "{base}/" ],
280
+ scopes: %w[openid profile email],
281
+ guide: "https://kb.synology.com/en-global/DSM/help/DSM/AdminCenter/connection_sso_client",
282
+ settings: [
283
+ [ "Profile", "OIDC" ], [ "Name", "{tenant}" ], [ "Well Known URL", "{discovery}" ],
284
+ [ "Client ID", "{client_id}" ], [ "Client secret", "{client_secret}" ],
285
+ [ "Scope", "openid profile email" ], [ "Username claim", "preferred_username" ], [ "Redirect URI", "{base}" ]
286
+ ]
185
287
  }
186
288
  ].map(&:freeze).freeze
187
289
 
@@ -0,0 +1,41 @@
1
+ require "socket"
2
+ require "zlib"
3
+
4
+ module Masks
5
+ module Server
6
+ class BootTask
7
+ WINDOW = 10.minutes
8
+
9
+ Outcome = Struct.new(:name, :host, :value, :done) do
10
+ def done? = done
11
+ end
12
+
13
+ class << self
14
+ def once(name, fingerprint: nil)
15
+ key = "masks:boot:#{name}:#{Digest::SHA256.hexdigest(fingerprint.to_s)[0, 16]}"
16
+ host = Socket.gethostname
17
+ outcome = nil
18
+
19
+ ActiveRecord::Base.transaction do
20
+ ActiveRecord::Base.connection.execute("SELECT pg_advisory_xact_lock(#{lock_id(name)})")
21
+ done_by = ::Rails.cache.read(key)
22
+
23
+ if done_by
24
+ outcome = Outcome.new(name, done_by, nil, false)
25
+ else
26
+ value = yield
27
+ ::Rails.cache.write(key, host, expires_in: WINDOW)
28
+ outcome = Outcome.new(name, host, value, true)
29
+ end
30
+ end
31
+
32
+ outcome
33
+ end
34
+
35
+ def lock_id(name)
36
+ Zlib.crc32("masks:boot:#{name}")
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -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
 
@@ -377,6 +379,11 @@ module Masks
377
379
 
378
380
  def authenticate_secret(candidate)
379
381
  return true if public?
382
+
383
+ secret_matches?(candidate) && !secret_expired?
384
+ end
385
+
386
+ def secret_matches?(candidate)
380
387
  return false unless secret?
381
388
  return false if secret_digest.blank? || candidate.blank?
382
389
 
@@ -385,6 +392,10 @@ module Masks
385
392
  false
386
393
  end
387
394
 
395
+ def secret_expired?
396
+ secret_expires_at.present? && secret_expires_at.past?
397
+ end
398
+
388
399
  def redirect_uri?(candidate)
389
400
  redirect_uris.include?(candidate.to_s) || redirect_uris.any? { |registered| same_loopback?(registered, candidate.to_s) }
390
401
  end
@@ -445,6 +456,10 @@ module Masks
445
456
  refresh_token_lifetime&.seconds || RefreshToken.lifetime
446
457
  end
447
458
 
459
+ def refresh_absolute_lifetime
460
+ refresh_token_absolute_lifetime&.seconds || RefreshToken.absolute_lifetime
461
+ end
462
+
448
463
  def admits?(actor)
449
464
  allowed_groups.empty? || actor.group_labels.intersect?(allowed_groups)
450
465
  end
@@ -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
 
@@ -2,7 +2,7 @@ module Masks
2
2
  module Server
3
3
  class EnrollmentToken < Token
4
4
  SHORTEST = 1.minute
5
- LONGEST = 1.hour
5
+ LONGEST = 1.day
6
6
 
7
7
  class << self
8
8
  def lifetime
@@ -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
 
@@ -145,6 +146,7 @@ module Masks
145
146
  SIGNAL_STREAM_UPDATED = "signal_stream.updated".freeze
146
147
  SIGNAL_STREAM_DELETED = "signal_stream.deleted".freeze
147
148
  SIGNAL_UNDELIVERED = "signal.undelivered".freeze
149
+ TEXT_UNDELIVERED = "text.undelivered".freeze
148
150
  SIGNAL_RECEIVED = "signal.received".freeze
149
151
 
150
152
  ORGANIZATION_CREATED = "organization.created".freeze
@@ -166,10 +168,13 @@ module Masks
166
168
  CUSTOM_DOMAIN_SERVED = "custom_domain.served".freeze
167
169
  CUSTOM_DOMAIN_STOPPED = "custom_domain.stopped".freeze
168
170
 
171
+ JOB_FAILED = "job.failed".freeze
172
+
169
173
  GRAVE = [
170
174
  LOGIN_REFUSED, LOGIN_THROTTLED, LOGOUT_UNDELIVERED, REFRESH_REUSED, STREAM_FAILED,
171
175
  DEVICE_BLOCKED, ACTOR_DELETED, AUTHENTICATOR_DISABLED, CONNECTION_REFUSED, DELEGATION_REFUSED,
172
- EXCHANGE_REFUSED, SIGN_IN_RISKY, SIGNAL_UNDELIVERED, SIGNAL_RECEIVED, MEMBERSHIP_ROLE_KEPT, ORGANIZATION_OWNERLESS
176
+ EXCHANGE_REFUSED, SIGN_IN_RISKY, SIGNAL_UNDELIVERED, TEXT_UNDELIVERED, SIGNAL_RECEIVED, MEMBERSHIP_ROLE_KEPT,
177
+ ORGANIZATION_OWNERLESS, JOB_FAILED
173
178
  ].freeze
174
179
 
175
180
  ACTIONS = constants(false).filter_map do |name|
@@ -0,0 +1,25 @@
1
+ module Masks
2
+ module Server
3
+ module JobFailures
4
+ KEEP = 2.days
5
+ PREFIX = "masks:jobs:failed_tenants:".freeze
6
+
7
+ module_function
8
+
9
+ def record(job, subdomains)
10
+ ::Rails.cache.write("#{PREFIX}#{job}", subdomains.sort, expires_in: KEEP)
11
+ end
12
+
13
+ def last(job)
14
+ ::Rails.cache.read("#{PREFIX}#{job}") || []
15
+ end
16
+
17
+ def named(jobs)
18
+ jobs.map do |job|
19
+ tenants = last(job)
20
+ tenants.empty? ? job : "#{job} (#{tenants.join(', ')})"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -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?
@@ -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
@@ -72,11 +72,10 @@ module Masks
72
72
  end
73
73
 
74
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
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?
78
77
 
79
- update!(changes) if changes.any?
78
+ save! if changed?
80
79
  end
81
80
 
82
81
  def bound?