masks-server 0.12.0 → 0.14.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/masks/server/scim_endpoint.rb +2 -2
  3. data/app/controllers/masks/server/account_exports_controller.rb +2 -2
  4. data/app/controllers/masks/server/addresses_controller.rb +2 -3
  5. data/app/controllers/masks/server/authenticator_apps_controller.rb +2 -3
  6. data/app/controllers/masks/server/authorize_controller.rb +2 -2
  7. data/app/controllers/masks/server/device_authorizations_controller.rb +2 -2
  8. data/app/controllers/masks/server/device_verifications_controller.rb +2 -2
  9. data/app/controllers/masks/server/enrollments_controller.rb +4 -4
  10. data/app/controllers/masks/server/forward_auth_controller.rb +2 -2
  11. data/app/controllers/masks/server/introspections_controller.rb +2 -2
  12. data/app/controllers/masks/server/logins_controller.rb +10 -12
  13. data/app/controllers/masks/server/manage_controller.rb +1 -0
  14. data/app/controllers/masks/server/organization_members_controller.rb +2 -2
  15. data/app/controllers/masks/server/passwords_controller.rb +2 -3
  16. data/app/controllers/masks/server/pushed_authorizations_controller.rb +2 -2
  17. data/app/controllers/masks/server/received_signals_controller.rb +2 -2
  18. data/app/controllers/masks/server/registrations_controller.rb +2 -2
  19. data/app/controllers/masks/server/revocations_controller.rb +2 -2
  20. data/app/controllers/masks/server/saml_controller.rb +2 -2
  21. data/app/controllers/masks/server/scim/metadata_controller.rb +1 -1
  22. data/app/controllers/masks/server/sign_in_approvals_controller.rb +2 -3
  23. data/app/controllers/masks/server/tokens_controller.rb +2 -2
  24. data/app/controllers/masks/server/verifications_controller.rb +2 -3
  25. data/app/graphql/masks/server/manage/types/health_check_type.rb +1 -1
  26. data/app/ldap/masks/server/ldap/gate.rb +14 -16
  27. data/app/models/masks/server/app_passwords.rb +7 -13
  28. data/app/models/masks/server/app_preset.rb +1 -1
  29. data/app/models/masks/server/client.rb +24 -0
  30. data/app/models/masks/server/client_metadata_document.rb +4 -8
  31. data/app/models/masks/server/confirmation_code.rb +2 -2
  32. data/app/models/masks/server/declared.rb +3 -1
  33. data/app/models/masks/server/event.rb +2 -2
  34. data/app/models/masks/server/health.rb +70 -3
  35. data/app/models/masks/server/saml_identity/farewells.rb +1 -0
  36. data/app/models/masks/server/tenant.rb +48 -4
  37. data/app/models/masks/server/throttle.rb +165 -0
  38. data/app/views/masks/server/handshakes/show.html.erb +2 -8
  39. data/app/views/masks/server/logins/_consent.html.erb +43 -44
  40. data/app/views/masks/server/logins/_enrol.html.erb +1 -1
  41. data/app/views/masks/server/logins/_setup_configure.html.erb +2 -6
  42. data/app/views/masks/server/logins/_signup.html.erb +2 -9
  43. data/app/views/masks/server/logins/_signup_credentials.html.erb +1 -2
  44. data/app/views/masks/server/logins/_signup_head.html.erb +1 -11
  45. data/config/locales/de/account.yml +4 -4
  46. data/config/locales/de/handshakes.yml +6 -6
  47. data/config/locales/de/logins.yml +44 -30
  48. data/config/locales/de/manage/health.yml +9 -2
  49. data/config/locales/en/account.yml +4 -4
  50. data/config/locales/en/handshakes.yml +6 -6
  51. data/config/locales/en/logins.yml +44 -30
  52. data/config/locales/en/manage/health.yml +9 -2
  53. data/config/locales/es/account.yml +4 -4
  54. data/config/locales/es/handshakes.yml +6 -6
  55. data/config/locales/es/logins.yml +42 -28
  56. data/config/locales/es/manage/health.yml +9 -2
  57. data/config/locales/fr/account.yml +4 -4
  58. data/config/locales/fr/handshakes.yml +6 -6
  59. data/config/locales/fr/logins.yml +43 -29
  60. data/config/locales/fr/manage/health.yml +9 -2
  61. data/db/migrate/20261031100000_default_key_rotation_to_ninety_days.rb +5 -0
  62. data/lib/generators/masks/server/install/install_generator.rb +19 -0
  63. data/lib/masks/server/configuration.rb +14 -8
  64. data/lib/masks/server/engine.rb +2 -1
  65. data/lib/masks/server/forwarding.rb +26 -3
  66. data/lib/masks/server/schema_reference.rb +1 -1
  67. data/lib/masks/server/tenancy/middleware.rb +4 -7
  68. data/lib/masks/server/tenant_isolation.rb +12 -0
  69. data/public/masks-assets/.vite/manifest.json +3 -3
  70. data/public/masks-assets/assets/app-Cfbt-A5V.css +2 -0
  71. data/public/masks-assets/assets/login-BDggujiZ.js +6 -0
  72. data/public/masks-assets/assets/login-BDggujiZ.js.map +1 -0
  73. data/public/masks-assets/assets/{manage-DpVxe5sv.js → manage-VgOqKj7N.js} +2 -2
  74. data/public/masks-assets/assets/manage-VgOqKj7N.js.map +1 -0
  75. data/version.txt +1 -1
  76. metadata +8 -6
  77. data/public/masks-assets/assets/app-BXOScMjL.css +0 -2
  78. data/public/masks-assets/assets/login-C21_jb6L.js +0 -4
  79. data/public/masks-assets/assets/login-C21_jb6L.js.map +0 -1
  80. data/public/masks-assets/assets/manage-DpVxe5sv.js.map +0 -1
@@ -14,7 +14,7 @@ module Masks
14
14
  SESSION_EXPIRED = "session.expired".freeze
15
15
  LOGOUT_UNDELIVERED = "logout.undelivered".freeze
16
16
  LOGIN_REFUSED = "login.refused".freeze
17
- LOGIN_THROTTLED = "login.throttled".freeze
17
+ LIMIT_REACHED = "limit.reached".freeze
18
18
 
19
19
  ACCOUNT_CREATED = "account.created".freeze
20
20
  ACCOUNT_EXPORTED = "account.exported".freeze
@@ -181,7 +181,7 @@ module Masks
181
181
  RELEASE_AVAILABLE = "release.available".freeze
182
182
 
183
183
  GRAVE = [
184
- LOGIN_REFUSED, LOGIN_THROTTLED, LOGOUT_UNDELIVERED, REFRESH_REUSED, STREAM_FAILED,
184
+ LOGIN_REFUSED, LIMIT_REACHED, LOGOUT_UNDELIVERED, REFRESH_REUSED, STREAM_FAILED,
185
185
  DEVICE_BLOCKED, ACTOR_DELETED, AUTHENTICATOR_DISABLED, CONNECTION_REFUSED, DELEGATION_REFUSED,
186
186
  EXCHANGE_REFUSED, SIGN_IN_RISKY, SIGNAL_UNDELIVERED, TEXT_UNDELIVERED, SIGNAL_RECEIVED, MEMBERSHIP_ROLE_KEPT,
187
187
  ORGANIZATION_OWNERLESS, JOB_FAILED, HEALTH_FAILING
@@ -10,11 +10,19 @@ module Masks
10
10
  end
11
11
 
12
12
  SERVER = %w[jobs worker queue ldap metrics].freeze
13
- TENANT = %w[mail origin domains keys].freeze
13
+ TENANT = %w[mail origin domains keys proxy].freeze
14
14
  NAMES = (SERVER + TENANT).freeze
15
15
 
16
16
  FAILING_KEY = "masks:health:failing".freeze
17
17
  ORIGIN_PREFIX = "masks:health:origin:".freeze
18
+ PROXY_KEY = "masks:health:proxy".freeze
19
+ PROXY_ADDRESSES = 5
20
+ PROXY_LOG = {
21
+ "untrusted" => "proxy: forwarded headers arrived from %{address}, which MASKS_TRUSTED_PROXIES does not list, so they were ignored",
22
+ "plain" => "proxy: %{address} forwards requests as plain http; have it send X-Forwarded-Proto: https"
23
+ }.freeze
24
+ ONE_ADDRESS_FLOOR = 20
25
+ LOOPBACK = /\A(?:(?:.+\.)?localhost|127(?:\.\d{1,3}){3}|\[::1\])\z/i
18
26
  KEEP = 1.day
19
27
  NOTE_EVERY = 10.minutes
20
28
  SMTP_TIMEOUT = 5
@@ -37,7 +45,7 @@ module Masks
37
45
  end
38
46
 
39
47
  def tenant_results
40
- [ mail_result, origin_result, domains_result, keys_result ]
48
+ [ mail_result, origin_result, domains_result, keys_result, proxy_result ]
41
49
  end
42
50
 
43
51
  def store!(results)
@@ -203,6 +211,65 @@ module Masks
203
211
  "#{ORIGIN_PREFIX}#{tenant.id}"
204
212
  end
205
213
 
214
+ def note_proxy!(kind, address)
215
+ return if address.blank? || !PROXY_LOG.key?(kind)
216
+
217
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
218
+ key = "proxy:#{kind}:#{address}"
219
+ last = noted[key]
220
+
221
+ return if last && now - last < NOTE_EVERY
222
+
223
+ noted[key] = now
224
+ ::Rails.logger.warn(format(PROXY_LOG.fetch(kind), address: address))
225
+
226
+ held = ::Rails.cache.read(PROXY_KEY) || {}
227
+ seen = (held[kind] ||= {})
228
+ seen[address] = Time.current.utc.iso8601 if seen.key?(address) || seen.size < PROXY_ADDRESSES
229
+ ::Rails.cache.write(PROXY_KEY, held, expires_in: KEEP)
230
+ end
231
+
232
+ def forget_proxy!
233
+ noted.keys.grep(/\Aproxy:/).each { |key| noted.delete(key) }
234
+ ::Rails.cache.delete(PROXY_KEY)
235
+ end
236
+
237
+ def proxy_result
238
+ held = ::Rails.cache.read(PROXY_KEY) || {}
239
+ untrusted = held["untrusted"].to_h.keys.sort
240
+ plain = held["plain"].to_h.keys.sort
241
+
242
+ return warning("proxy", "untrusted", addresses: untrusted) if untrusted.any?
243
+ return warning("proxy", "plain", addresses: plain) if plain.any?
244
+
245
+ address, count = sole_private_address
246
+
247
+ return warning("proxy", "one", address: address, count: count) if address
248
+
249
+ ok("proxy", "quiet")
250
+ end
251
+
252
+ def sole_private_address
253
+ origin = Current.tenant&.public_origin
254
+
255
+ return if origin.nil? || URI.parse(origin).host.to_s.match?(LOOPBACK)
256
+
257
+ recent = Event.where(created_at: 1.hour.ago..).where.not(ip_address: [ nil, "" ])
258
+ addresses = recent.distinct.limit(2).pluck(:ip_address)
259
+
260
+ return unless addresses.size == 1
261
+
262
+ count = recent.count
263
+
264
+ return if count < ONE_ADDRESS_FLOOR
265
+
266
+ address = IPAddr.new(addresses.first)
267
+
268
+ [ addresses.first, count ] if address.private? || address.loopback? || address.link_local?
269
+ rescue IPAddr::InvalidAddressError, URI::InvalidURIError
270
+ nil
271
+ end
272
+
206
273
  def domains_result
207
274
  return ok("domains", "none") unless DomainClaim.exists?
208
275
 
@@ -221,7 +288,7 @@ module Masks
221
288
  days = ((Time.current - active.activated_at) / 1.day).floor
222
289
  period = Current.tenant.key_rotation_days
223
290
 
224
- return ok("keys", "manual", days: days) if period.nil?
291
+ return warning("keys", "manual", days: days) if period.nil?
225
292
  return warning("keys", "stale", days: days, period: period) if days > period
226
293
 
227
294
  ok("keys", "fresh", days: days, period: period)
@@ -11,6 +11,7 @@ module Masks
11
11
  held = ::Rails.cache.read(key)
12
12
 
13
13
  return nil unless held.is_a?(Hash) && held["client_id"] == client.client_id
14
+ return nil unless Replay.first?("saml-farewell", request_id, within: client.id, expires_in: WAIT)
14
15
 
15
16
  ::Rails.cache.delete(key)
16
17
  new(held["steps"], held["finish"])
@@ -31,6 +31,21 @@ module Masks
31
31
  end
32
32
  end
33
33
 
34
+ class Pooled < StandardError
35
+ def initialize(message = nil)
36
+ super(
37
+ message ||
38
+ "this server reaches Postgres through a pooler that hands out a different server " \
39
+ "connection for each transaction. The tenant masks sets on one connection is gone from " \
40
+ "the next, so a query would see no tenant's rows, or another tenant's. Connect masks to " \
41
+ "Postgres directly, or run the pooler in session mode."
42
+ )
43
+ end
44
+ end
45
+
46
+ POOL_PROBE = "masks.pool_probe".freeze
47
+ POOL_PROBE_ROUNDS = 3
48
+
34
49
  encrypts :pairwise_salt, :setup_token, key_provider: Server.tenant_key_provider
35
50
 
36
51
  attribute :uuid, default: -> { SecureRandom.uuid }
@@ -348,10 +363,28 @@ module Masks
348
363
 
349
364
  raise Exposed, held&.fetch("rolname", nil) unless held && held["bypasses"] == false
350
365
  raise Owner, held["rolname"] if held["owns"] && guards_ownership?
366
+ raise Pooled if pooled?
351
367
 
352
368
  @isolated = true
353
369
  end
354
370
 
371
+ def pooled?
372
+ return false if connection_pool.size < 2
373
+
374
+ POOL_PROBE_ROUNDS.times do
375
+ mine = SecureRandom.hex(8)
376
+ connection.exec_query("SELECT set_config($1, $2, false)", "probe", [ POOL_PROBE, mine ])
377
+
378
+ return false unless touched_another_connection?
379
+
380
+ return true unless connection.select_value("SELECT current_setting('#{POOL_PROBE}', true)") == mine
381
+ end
382
+
383
+ false
384
+ rescue ActiveRecord::ConnectionTimeoutError
385
+ false
386
+ end
387
+
355
388
  def role_privileges
356
389
  connection.select_one(<<~SQL)
357
390
  SELECT rolname, rolsuper OR rolbypassrls AS bypasses,
@@ -383,23 +416,34 @@ module Masks
383
416
  end
384
417
 
385
418
  def clear!
386
- enter(nil)
419
+ enter(nil, for_transaction: false)
387
420
  end
388
421
 
389
422
  private
390
423
 
391
- def enter(tenant)
424
+ def enter(tenant, for_transaction: connection.transaction_open?)
392
425
  Current.tenant = tenant
393
426
 
394
427
  connection.exec_query(
395
- "SELECT set_config($1, $2, false)", "tenant",
396
- [ TenantIsolation::SETTING, tenant&.id.to_s ]
428
+ "SELECT set_config($1, $2, $3)", "tenant",
429
+ [ TenantIsolation::SETTING, tenant&.id.to_s, for_transaction ]
397
430
  )
398
431
 
399
432
  connection.clear_query_cache
400
433
  rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::ConnectionFailed
401
434
  nil
402
435
  end
436
+
437
+ def touched_another_connection?
438
+ other = connection_pool.checkout
439
+
440
+ return false if other.equal?(connection)
441
+
442
+ other.exec_query("SELECT set_config($1, $2, false)", "probe", [ POOL_PROBE, "another connection" ])
443
+ true
444
+ ensure
445
+ connection_pool.checkin(other) if other
446
+ end
403
447
  end
404
448
 
405
449
  def signing_key
@@ -0,0 +1,165 @@
1
+ module Masks
2
+ module Server
3
+ module Throttle
4
+ class Invalid < StandardError; end
5
+
6
+ Budget = Struct.new(:name, :to, :within, :about, :variable, keyword_init: true) do
7
+ def key
8
+ name.to_s
9
+ end
10
+
11
+ def limits
12
+ { to: to, within: within }
13
+ end
14
+
15
+ def reference
16
+ "| `#{variable}` | `#{key}` | #{to} in #{within.inspect} | #{about} |"
17
+ end
18
+ end
19
+
20
+ DEFAULTS = [
21
+ Budget.new(name: :attempt, to: 10, within: 3.minutes, variable: "MASKS_ATTEMPT_LIMIT",
22
+ about: "Sign-in attempts from one IP address. Includes sign-in through a provider, failed LDAP binds, and app passwords."),
23
+ Budget.new(name: :account_attempt, to: 5, within: 3.minutes, variable: "MASKS_ACCOUNT_ATTEMPT_LIMIT",
24
+ about: "Sign-in attempts against one account, from any IP address. Includes failed LDAP binds and app passwords. " \
25
+ "Also how often a signed-in account changes its password, email address, or phone number, adds an " \
26
+ "authenticator app, or approves a sign-in on another device."),
27
+ Budget.new(name: :recovery, to: 5, within: 15.minutes, variable: "MASKS_RECOVERY_LIMIT",
28
+ about: "Codes and links sent by email or text message. Counted by IP address during sign-in, and by account for " \
29
+ "every address or phone number a code goes to, including a phone added or changed during sign-in."),
30
+ Budget.new(name: :registration, to: 10, within: 10.minutes, variable: "MASKS_REGISTRATION_LIMIT",
31
+ about: "Dynamic client registrations from one IP address, and new client metadata documents."),
32
+ Budget.new(name: :device_code, to: 30, within: 3.minutes, variable: "MASKS_DEVICE_CODE_LIMIT",
33
+ about: "Device user codes entered from one IP address."),
34
+ Budget.new(name: :claim, to: 3, within: 1.hour, variable: "MASKS_CLAIM_LIMIT",
35
+ about: "Tenants claimed from one IP address, when a subdomain template claims a tenant on its first visit. " \
36
+ "Counted across the whole server."),
37
+ Budget.new(name: :claim_ceiling, to: 30, within: 1.hour, variable: "MASKS_CLAIM_CEILING",
38
+ about: "Tenants claimed from all IP addresses together, under the same template. Counted across the whole server."),
39
+ Budget.new(name: :token, to: 60, within: 1.minute, variable: "MASKS_TOKEN_LIMIT", about: "Token requests from one IP address."),
40
+ Budget.new(name: :pushed_authorization, to: 60, within: 1.minute, variable: "MASKS_PUSHED_AUTHORIZATION_LIMIT", about: "Pushed authorization requests from one IP address."),
41
+ Budget.new(name: :device_authorization, to: 60, within: 1.minute, variable: "MASKS_DEVICE_AUTHORIZATION_LIMIT", about: "Device authorization requests from one IP address."),
42
+ Budget.new(name: :introspection, to: 120, within: 1.minute, variable: "MASKS_INTROSPECTION_LIMIT", about: "Token introspections from one IP address."),
43
+ Budget.new(name: :revocation, to: 120, within: 1.minute, variable: "MASKS_REVOCATION_LIMIT", about: "Token revocations from one IP address."),
44
+ Budget.new(name: :saml, to: 60, within: 1.minute, variable: "MASKS_SAML_LIMIT", about: "SAML sign-in and logout requests from one IP address."),
45
+ Budget.new(name: :forward_auth, to: 30, within: 1.minute, variable: "MASKS_FORWARD_AUTH_LIMIT", about: "Forward auth sign-ins started or finished from one IP address."),
46
+ Budget.new(name: :enrollment, to: 30, within: 1.minute, variable: "MASKS_ENROLLMENT_LIMIT", about: "Enrollment tokens redeemed from one IP address."),
47
+ Budget.new(name: :app_enrollment, to: 60, within: 1.hour, variable: "MASKS_APP_ENROLLMENT_LIMIT", about: "Enrollment tokens one app mints."),
48
+ Budget.new(name: :invitation, to: 30, within: 1.minute, variable: "MASKS_INVITATION_LIMIT", about: "Organization invitations one account sends or sends again."),
49
+ Budget.new(name: :export, to: 5, within: 1.hour, variable: "MASKS_EXPORT_LIMIT", about: "Account exports one account asks for."),
50
+ Budget.new(name: :access_request, to: 1, within: 1.hour, variable: "MASKS_ACCESS_REQUEST_LIMIT", about: "Times one account asks for access to one app."),
51
+ Budget.new(name: :signal, to: 600, within: 1.minute, variable: "MASKS_SIGNAL_LIMIT", about: "Security events received from one IP address."),
52
+ Budget.new(name: :scim, to: 600, within: 1.minute, variable: "MASKS_SCIM_LIMIT", about: "SCIM provisioning requests from one IP address.")
53
+ ].freeze
54
+
55
+ NAMES = DEFAULTS.map(&:key).freeze
56
+
57
+ module_function
58
+
59
+ def budgets
60
+ @budgets ||= DEFAULTS.to_h { |budget| [ budget.name, budget.dup.tap { |held| held.to = limit_for(budget) } ] }
61
+ end
62
+
63
+ def budget(name)
64
+ budgets.fetch(name.to_sym) { raise Invalid, "no limit is called #{name}. The limits are #{NAMES.join(', ')}." }
65
+ end
66
+
67
+ def limit(name)
68
+ budget(name).to
69
+ end
70
+
71
+ def limits(name)
72
+ budget(name).limits
73
+ end
74
+
75
+ def refused(name, &block)
76
+ held = budget(name)
77
+
78
+ proc { Throttle.hit!(held.name); instance_exec(&block) }
79
+ end
80
+
81
+ def key(name, by, scope: nil, named: nil)
82
+ held = budget(name)
83
+
84
+ [ "rate-limit", scope || "masks/server/#{held.key}", named, by ].compact.join(":")
85
+ end
86
+
87
+ def count!(name, by, scope: nil, named: nil)
88
+ ::Rails.cache.increment(key(name, by, scope: scope, named: named), 1, expires_in: budget(name).within)
89
+ end
90
+
91
+ def exceeded?(name, by, scope: nil, named: nil, times: 1)
92
+ ::Rails.cache.read(key(name, by, scope: scope, named: named)).to_i >= budget(name).to * times
93
+ end
94
+
95
+ def crowded?(name, by, scope: nil, named: nil, times: 1)
96
+ held = budget(name)
97
+
98
+ ::Rails.cache.increment(key(name, by, scope: scope, named: named), 1, expires_in: held.within).to_i > held.to * times
99
+ end
100
+
101
+ def hit!(name, actor: nil, client: nil, address: Current.ip_address, **details)
102
+ held = budget(name)
103
+
104
+ ::Rails.logger.info("masks: the #{held.key} limit of #{held.to} in #{held.within.inspect} was reached" \
105
+ "#{address.present? ? " from #{address}" : ''}")
106
+
107
+ return if Current.tenant.nil?
108
+ return unless ::Rails.cache.write(key(name, "reached:#{address}"), true, unless_exist: true, expires_in: held.within)
109
+
110
+ Event.record!(Event::LIMIT_REACHED, actor: actor, by: nil, client: client, limit: held.key, **details)
111
+ end
112
+
113
+ def reference
114
+ DEFAULTS.map(&:reference)
115
+ end
116
+
117
+ def reset!
118
+ @budgets = nil
119
+ @declared = nil
120
+ end
121
+
122
+ def limit_for(budget)
123
+ from_env(budget) || declared.fetch(budget.key, nil) || budget.to
124
+ end
125
+
126
+ def from_env(budget)
127
+ value = ENV[budget.variable]
128
+
129
+ return nil if value.blank?
130
+
131
+ whole!(value, budget.variable)
132
+ end
133
+
134
+ def declared
135
+ @declared ||= read_declared(::Rails.configuration.masks.config_path)
136
+ end
137
+
138
+ def read_declared(path)
139
+ return {} if path.blank?
140
+
141
+ document = YAML.safe_load_file(path, aliases: false) || {}
142
+ limits = document.is_a?(Hash) ? document.fetch("limits", nil) || {} : {}
143
+
144
+ raise Invalid, "limits in #{path} must map each limit's name to a whole number" unless limits.is_a?(Hash)
145
+
146
+ unknown = limits.keys.map(&:to_s) - NAMES
147
+ raise Invalid, "#{path} sets limits called #{unknown.join(', ')}, which do not exist. The limits are #{NAMES.join(', ')}." if unknown.any?
148
+
149
+ limits.to_h { |name, value| [ name.to_s, whole!(value, "limits.#{name} in #{path}") ] }
150
+ rescue Errno::ENOENT, Psych::Exception
151
+ {}
152
+ end
153
+
154
+ def whole!(value, where)
155
+ count = Integer(value.to_s, 10)
156
+
157
+ raise Invalid, "#{where} is #{value.inspect}. A limit is a whole number, 1 or more." if count < 1
158
+
159
+ count
160
+ rescue ArgumentError, TypeError
161
+ raise Invalid, "#{where} is #{value.inspect}. A limit is a whole number, 1 or more."
162
+ end
163
+ end
164
+ end
165
+ end
@@ -12,21 +12,15 @@
12
12
  <% hot, rest = @scopes.to_a.partition { |scope, _| heeded_scope?(scope) } %>
13
13
  <% after_sign_out = @handshake.try(:post_logout_redirect_uri) %>
14
14
 
15
- <div class="auth-pair">
15
+ <div class="auth-solo">
16
16
  <span class="auth-mark auth-mark-client" aria-hidden="true"><%= name.to_s.first&.upcase %></span>
17
- <span class="auth-wire"></span>
18
- <%= tenant_mark(Masks::Server::Current.tenant) %>
19
17
  </div>
20
18
 
21
19
  <div class="prompt-head">
22
20
  <h1 class="prompt-title"><%= t(@existing ? ".title_again" : ".title", name: name, tenant: tenant) %></h1>
23
21
 
24
- <p class="handshake-origin">
25
- <span class="handshake-origin-label"><%= t(".runs_at") %></span>
26
- <span class="handshake-origin-value"><%= @handshake.origin %></span>
27
- </p>
28
-
29
22
  <p class="prompt-lede">
23
+ <%= t(".runs_at", name: name, origin: @handshake.origin) %>
30
24
  <%= t(@existing ? ".lede_again_html" : ".lede_html", name: name, tenant: tenant) %>
31
25
  </p>
32
26
  </div>
@@ -1,15 +1,14 @@
1
1
  <% state = login.state("consent") %>
2
2
  <% client = login.client_json || {} %>
3
3
  <% hot, rest = ranked_scopes(Masks::Server::ResourceMetadata.describe(state.audience, state.scopes)) %>
4
+ <% all = hot + rest %>
4
5
 
5
- <div class="auth-pair">
6
+ <div class="auth-solo">
6
7
  <% if client["logo"] %>
7
8
  <%= image_tag client["logo"], alt: "", class: "auth-mark auth-mark-client auth-mark-logo", referrerpolicy: "no-referrer" %>
8
9
  <% else %>
9
10
  <span class="auth-mark auth-mark-client" aria-hidden="true"><%= client["name"].to_s.strip.first&.upcase %></span>
10
11
  <% end %>
11
- <span class="auth-wire"></span>
12
- <%= tenant_mark(Masks::Server::Current.tenant) %>
13
12
  </div>
14
13
 
15
14
  <%= render "masks/server/logins/head", title: t(".title", client: login.client&.name) %>
@@ -20,48 +19,30 @@
20
19
  <div class="ledger-row">
21
20
  <span class="ledger-label"><%= t(".access") %></span>
22
21
 
23
- <% if hot.empty? && rest.empty? %>
22
+ <% if all.empty? %>
24
23
  <p class="empty"><%= t(".no_access") %></p>
24
+ <% else %>
25
+ <ul class="plain-list">
26
+ <% all.each do |_, description| %>
27
+ <li><%= description %></li>
28
+ <% end %>
29
+ </ul>
25
30
  <% end %>
26
-
27
- <ul class="grant-scopes">
28
- <% hot.each do |scope, description| %>
29
- <li class="grant-scope grant-scope-hot">
30
- <span><%= description %></span>
31
- <span class="chip-key"><%= scope %></span>
32
- </li>
33
- <% end %>
34
-
35
- <% if hot.any? && rest.any? %>
36
- <li class="grant-scope-split"></li>
37
- <% end %>
38
-
39
- <% rest.each do |scope, description| %>
40
- <li class="grant-scope">
41
- <span><%= description %></span>
42
- <span class="chip-key"><%= scope %></span>
43
- </li>
44
- <% end %>
45
- </ul>
46
31
  </div>
47
32
 
48
33
  <% if state.audience.any? %>
49
34
  <div class="ledger-row">
50
- <span class="ledger-label"><%= t(".audience") %></span>
51
- <% state.audience.each do |resource| %>
52
- <span class="ledger-value aside-mono"><%= resource %></span>
53
- <% end %>
35
+ <span class="aside"><%= t(".audience", resources: state.audience.join(", ")) %></span>
54
36
  </div>
55
37
  <% end %>
56
38
 
57
39
  <% if (details = state.details&.described).present? %>
58
40
  <div class="ledger-row">
59
41
  <span class="ledger-label"><%= t(".details") %></span>
60
- <ul class="grant-scopes">
42
+ <ul class="plain-list">
61
43
  <% details.each do |detail| %>
62
- <li class="grant-scope grant-scope-hot grant-detail">
63
- <span><%= detail["label"] %></span>
64
- <span class="chip-key"><%= detail["type"] %></span>
44
+ <li>
45
+ <%= detail["label"] %>
65
46
  <% if detail["fields"].any? %>
66
47
  <dl class="grant-fields">
67
48
  <% detail["fields"].each do |name, value| %>
@@ -75,23 +56,41 @@
75
56
  </ul>
76
57
  </div>
77
58
  <% end %>
59
+ </div>
78
60
 
79
- <% if client["returnsTo"] %>
61
+ <% links = { "site" => t(".site", client: client["name"]), "terms" => t(".terms"), "privacy" => t(".privacy") }.select { |key, _| client[key] } %>
62
+ <details class="disclosure handshake-more">
63
+ <summary class="textlink"><%= t(".more") %></summary>
64
+
65
+ <div class="slab">
80
66
  <div class="ledger-row">
81
- <span class="ledger-label"><%= t(".returns_to") %></span>
82
- <span class="ledger-value aside-mono"><%= client["returnsTo"] %></span>
67
+ <span class="ledger-label"><%= t(".scopes") %></span>
68
+ <ul class="grant-scopes">
69
+ <% all.each do |scope, description| %>
70
+ <li class="grant-scope">
71
+ <span><%= description %></span>
72
+ <span class="chip-key"><%= scope %></span>
73
+ </li>
74
+ <% end %>
75
+ </ul>
83
76
  </div>
84
- <% end %>
85
- </div>
86
77
 
87
- <% links = { "site" => t(".site", client: client["name"]), "terms" => t(".terms"), "privacy" => t(".privacy") }.select { |key, _| client[key] } %>
88
- <% if links.any? %>
89
- <p class="aside client-links">
90
- <% links.each do |key, label| %>
91
- <%= link_to label, client[key], class: "textlink", rel: "noopener noreferrer", target: "_blank" %>
78
+ <% if client["returnsTo"] %>
79
+ <div class="ledger-row">
80
+ <span class="ledger-label"><%= t(".returns_to") %></span>
81
+ <span class="ledger-value aside-mono"><%= client["returnsTo"] %></span>
82
+ </div>
92
83
  <% end %>
93
- </p>
94
- <% end %>
84
+
85
+ <% if links.any? %>
86
+ <p class="aside client-links">
87
+ <% links.each do |key, label| %>
88
+ <%= link_to label, client[key], class: "textlink", rel: "noopener noreferrer", target: "_blank" %>
89
+ <% end %>
90
+ </p>
91
+ <% end %>
92
+ </div>
93
+ </details>
95
94
 
96
95
  <div class="action-row">
97
96
  <%= form_with url: login_path, method: :post, id: "consent", class: "action-grow" do |form| %>
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div class="flow<%= " setup" if signing_up %>">
9
9
  <% if signing_up %>
10
- <%= render "masks/server/logins/signup_head", login: login, at: 2, mark: login.actor&.identifier.to_s %>
10
+ <%= render "masks/server/logins/signup_head", login: login, at: 2 %>
11
11
 
12
12
  <p class="aside"><%= enrolment["required"] ? t(".lede") : t(".lede_optional") %></p>
13
13
  <% else %>
@@ -1,7 +1,7 @@
1
1
  <% configure = login.state("configure").as_json["configure"] %>
2
2
 
3
3
  <div class="setup flow">
4
- <%= render "masks/server/logins/signup_head", login: login, at: 3, mark: login.actor&.identifier.to_s %>
4
+ <%= render "masks/server/logins/signup_head", login: login, at: 3 %>
5
5
 
6
6
  <%= form_with url: login_path, method: :post, id: "setup-configure", class: "flow" do |form| %>
7
7
  <%= hidden_field_tag :rid, login.rid %>
@@ -12,11 +12,6 @@
12
12
  <%= form.text_field :called, value: configure["called"], class: "control" %>
13
13
  <span class="field-hint"><%= t(".called_hint") %></span>
14
14
  </label>
15
-
16
- <div class="ledger-row">
17
- <span class="ledger-label"><%= t("logins.signing_up.origin") %></span>
18
- <span class="ledger-value aside-mono"><%= Masks::Server::Current.origin %></span>
19
- </div>
20
15
  </div>
21
16
 
22
17
  <button type="submit" name="event" value="setup-configure" class="action">
@@ -25,6 +20,7 @@
25
20
  <% end %>
26
21
 
27
22
  <p class="aside">
23
+ <%= t("logins.signing_up.origin", origin: Masks::Server::Current.origin) %>
28
24
  <%= t("logins.signing_up.once") %>
29
25
  <%= link_to t("logins.signing_up.docs"), Rails.configuration.masks.docs_url,
30
26
  class: "textlink", rel: "noopener", target: "_blank" %>
@@ -4,8 +4,7 @@
4
4
  <% done = ->(field) { " ledger-step-done" if value.(field).present? } %>
5
5
 
6
6
  <div class="setup flow">
7
- <%= render "masks/server/logins/signup_head", login: login, at: 1,
8
- mark: value.("nickname").presence || value.("email") %>
7
+ <%= render "masks/server/logins/signup_head", login: login, at: 1 %>
9
8
 
10
9
  <%= form_with url: login_path, method: :post, id: "signup", class: "flow" do |form| %>
11
10
  <%= hidden_field_tag :rid, login.rid %>
@@ -51,13 +50,6 @@
51
50
  <%= render "masks/server/logins/hint", login: login, field: "phone" %>
52
51
  </label>
53
52
  <% end %>
54
-
55
- <% if login.signing_up["firstRun"] %>
56
- <div class="ledger-row">
57
- <span class="ledger-label"><%= t("logins.signing_up.origin") %></span>
58
- <span class="ledger-value aside-mono"><%= Masks::Server::Current.origin %></span>
59
- </div>
60
- <% end %>
61
53
  </div>
62
54
 
63
55
  <% if signup["work"] %>
@@ -71,6 +63,7 @@
71
63
 
72
64
  <% if login.signing_up["firstRun"] %>
73
65
  <p class="aside">
66
+ <%= t("logins.signing_up.origin", origin: Masks::Server::Current.origin) %>
74
67
  <%= t("logins.signing_up.once") %>
75
68
  <%= link_to t("logins.signing_up.docs"), Rails.configuration.masks.docs_url,
76
69
  class: "textlink", rel: "noopener", target: "_blank" %>
@@ -2,8 +2,7 @@
2
2
  <% rows = %w[name nickname email phone].filter_map { |field| [ field, signup[field] ] if signup[field].present? } %>
3
3
 
4
4
  <div class="setup flow">
5
- <%= render "masks/server/logins/signup_head", login: login, at: 2,
6
- mark: signup["nickname"].presence || signup["email"] %>
5
+ <%= render "masks/server/logins/signup_head", login: login, at: 2 %>
7
6
 
8
7
  <%= form_with url: login_path, method: :post, id: "signup-credentials", class: "flow" do |form| %>
9
8
  <%= hidden_field_tag :rid, login.rid %>
@@ -1,20 +1,10 @@
1
1
  <% tenant = Masks::Server::Current.tenant&.name.to_s %>
2
2
  <% signing_up = login.signing_up %>
3
3
 
4
- <div class="auth-pair">
5
- <span class="auth-mark auth-mark-client<%= " auth-mark-pending" if mark.blank? %>" aria-hidden="true"><%= mark.to_s.first&.upcase %></span>
6
- <span class="auth-wire"></span>
7
- <% if signing_up["firstRun"] %>
8
- <img src="/masks-public/icon.svg" alt="" class="auth-mark auth-mark-rose">
9
- <% else %>
10
- <%= tenant_mark(Masks::Server::Current.tenant) %>
11
- <% end %>
12
- </div>
13
-
14
4
  <% if signing_up["firstRun"] %>
15
5
  <%= render "masks/server/logins/head", cap: t("logins.signing_up.setup_cap"), title: t("logins.signing_up.setup_title"), name: tenant %>
16
6
  <% else %>
17
- <%= render "masks/server/logins/head", title: t("logins.signing_up.signup_title"), name: tenant %>
7
+ <%= render "masks/server/logins/head", title: t("logins.signing_up.signup_title") %>
18
8
  <% end %>
19
9
 
20
10
  <%= render "masks/server/logins/steps", steps: signing_up["steps"], at: local_assigns[:at] || signing_up["steps"].length %>