add_auth 0.2.2 → 0.4.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +64 -17
  4. data/ROADMAP.md +98 -43
  5. data/SECURITY.md +8 -0
  6. data/app/controllers/add_auth/accounts_controller.rb +146 -0
  7. data/app/controllers/add_auth/assets_controller.rb +2 -1
  8. data/app/controllers/add_auth/external_identities_controller.rb +56 -0
  9. data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
  10. data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
  11. data/app/controllers/add_auth/reauthentications_controller.rb +5 -0
  12. data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
  13. data/app/jobs/add_auth/account_delivery_job.rb +14 -0
  14. data/app/jobs/add_auth/account_request_job.rb +17 -0
  15. data/app/mailers/add_auth/account_mailer.rb +12 -0
  16. data/app/views/add_auth/account_mailer/link.text.erb +7 -0
  17. data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
  18. data/app/views/add_auth/accounts/_delete.html.erb +6 -0
  19. data/app/views/add_auth/accounts/_email.html.erb +12 -0
  20. data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
  21. data/app/views/add_auth/accounts/_password.html.erb +12 -0
  22. data/app/views/add_auth/accounts/_proof.html.erb +16 -0
  23. data/app/views/add_auth/accounts/_register.html.erb +17 -0
  24. data/app/views/add_auth/accounts/_request.html.erb +17 -0
  25. data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
  26. data/app/views/add_auth/external_identities/_index.html.erb +22 -0
  27. data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
  28. data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
  29. data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
  30. data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
  31. data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
  32. data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
  33. data/app/views/add_auth/sessions/_list.html.erb +1 -1
  34. data/app/views/add_auth/sessions/index.html.erb +7 -0
  35. data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
  36. data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
  37. data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
  38. data/app/views/layouts/add_auth/authentication.html.erb +4 -3
  39. data/exe/add_auth-preflight +26 -0
  40. data/lib/add_auth/configuration.rb +78 -4
  41. data/lib/add_auth/core/access_policy.rb +11 -1
  42. data/lib/add_auth/core/account_lifecycle.rb +295 -0
  43. data/lib/add_auth/core/account_policy.rb +68 -0
  44. data/lib/add_auth/core/external_identities/evidence.rb +98 -0
  45. data/lib/add_auth/core/external_identities.rb +278 -0
  46. data/lib/add_auth/core/intake.rb +6 -1
  47. data/lib/add_auth/core/maintenance.rb +5 -3
  48. data/lib/add_auth/core/migration/account_adoption.rb +79 -0
  49. data/lib/add_auth/core/migration/readiness.rb +41 -0
  50. data/lib/add_auth/core/mobile_authentication.rb +35 -0
  51. data/lib/add_auth/core/mobile_handoffs.rb +83 -0
  52. data/lib/add_auth/core/mobile_profile.rb +46 -0
  53. data/lib/add_auth/core/mobile_response.rb +12 -0
  54. data/lib/add_auth/core/native_authentication.rb +77 -0
  55. data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
  56. data/lib/add_auth/core/passwords/credential.rb +49 -0
  57. data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
  58. data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
  59. data/lib/add_auth/core/remaining_factors.rb +24 -0
  60. data/lib/add_auth/core/sessions.rb +102 -17
  61. data/lib/add_auth/core/step_up.rb +13 -1
  62. data/lib/add_auth/migration/static_inventory.rb +116 -0
  63. data/lib/add_auth/rails/account_password_entry.rb +24 -0
  64. data/lib/add_auth/rails/authentication.rb +1 -1
  65. data/lib/add_auth/rails/authentication_pages.rb +6 -1
  66. data/lib/add_auth/rails/doctor.rb +80 -6
  67. data/lib/add_auth/rails/ejection.rb +1 -1
  68. data/lib/add_auth/rails/engine.rb +6 -1
  69. data/lib/add_auth/rails/migration/account_store.rb +41 -0
  70. data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
  71. data/lib/add_auth/rails/migration/key_type.rb +23 -0
  72. data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
  73. data/lib/add_auth/rails/password_adoption.rb +37 -0
  74. data/lib/add_auth/rails/provider_enrollment.rb +61 -0
  75. data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
  76. data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
  77. data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
  78. data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
  79. data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
  80. data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
  81. data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
  82. data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
  83. data/lib/add_auth/rails/runtime.rb +179 -8
  84. data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
  85. data/lib/add_auth/rails/stores/account_lock.rb +16 -7
  86. data/lib/add_auth/rails/stores/account_tokens.rb +104 -0
  87. data/lib/add_auth/rails/stores/authority.rb +28 -0
  88. data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
  89. data/lib/add_auth/rails/stores/external_identities.rb +91 -0
  90. data/lib/add_auth/rails/stores/maintenance.rb +1 -1
  91. data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
  92. data/lib/add_auth/rails/stores/security_events.rb +3 -6
  93. data/lib/add_auth/rails/user_lifecycle.rb +8 -10
  94. data/lib/add_auth/result.rb +1 -0
  95. data/lib/add_auth/version.rb +1 -1
  96. data/lib/add_auth.rb +4 -0
  97. data/lib/generators/add_auth/accounts/accounts_generator.rb +56 -0
  98. data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
  99. data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -0
  100. data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
  101. data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
  102. data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
  103. data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
  104. data/lib/generators/add_auth/email_link/email_link_generator.rb +4 -1
  105. data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
  106. data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
  107. data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
  108. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
  109. data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
  110. data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
  111. data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
  112. data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
  113. data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
  114. data/lib/generators/add_auth/feature_configuration.rb +21 -0
  115. data/lib/generators/add_auth/install/templates/initializer.rb +154 -46
  116. data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
  117. data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
  118. data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
  119. data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
  120. data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
  121. data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
  122. data/lib/generators/add_auth/notifications/notifications_generator.rb +3 -4
  123. data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
  124. data/lib/generators/add_auth/passkeys/passkeys_generator.rb +3 -21
  125. data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
  126. data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -1
  127. data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
  128. data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
  129. data/lib/tasks/add_auth.rake +18 -1
  130. metadata +101 -2
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require "add_auth/core/mobile_profile"
4
5
 
5
6
  module AddAuth
6
7
  module Core
@@ -10,14 +11,15 @@ module AddAuth
10
11
  end
11
12
  class Entry
12
13
  attr_reader :id, :current, :authenticated_with, :authenticated_at, :created_at,
13
- :last_seen_at, :expires_at, :ip_address, :user_agent
14
+ :last_seen_at, :expires_at, :ip_address, :user_agent, :transport, :client_id
14
15
 
15
16
  def initialize(id:, current:, authenticated_with:, authenticated_at:, created_at:,
16
- last_seen_at:, expires_at:, ip_address:, user_agent:)
17
+ last_seen_at:, expires_at:, ip_address:, user_agent:, transport: "browser", client_id: nil)
17
18
  @id, @current, @authenticated_with, @authenticated_at, @created_at = id, current,
18
19
  authenticated_with, authenticated_at, created_at
19
20
  @last_seen_at, @expires_at, @ip_address, @user_agent = last_seen_at, expires_at,
20
21
  ip_address, user_agent
22
+ @transport, @client_id = transport, client_id
21
23
  end
22
24
 
23
25
  def inspect = "#<AddAuth::Core::Sessions::Entry id=#{id} current=#{current.inspect}>"
@@ -27,7 +29,7 @@ module AddAuth
27
29
  Page = Struct.new(:entries, :next_cursor)
28
30
 
29
31
  def initialize(store:, digest:, eligible:, lifetime: 43_200, idle_timeout: 1800,
30
- legacy_bridge_until: nil, clock: Time, access_policy: nil)
32
+ legacy_bridge_until: nil, clock: Time, access_policy: nil, password_lifecycle: nil, remembered_profile: nil, mobile_profile: nil, on_sign_in: nil, verified_denial: nil)
31
33
  unless [lifetime, idle_timeout].all? { |n| n.is_a?(Numeric) && n.finite? && n.positive? } && idle_timeout <= lifetime
32
34
  raise ArgumentError, "session timeouts must be positive and idle_timeout <= lifetime"
33
35
  end
@@ -36,7 +38,19 @@ module AddAuth
36
38
  end
37
39
  @store, @digest, @eligible, @clock = store, digest, eligible, clock
38
40
  @access_policy = access_policy
41
+ @password_lifecycle = password_lifecycle
42
+ @on_sign_in = on_sign_in
43
+ @verified_denial = verified_denial
44
+ raise ArgumentError, "invalid mobile profile" unless mobile_profile.nil? || mobile_profile.is_a?(MobileProfile)
45
+ @mobile = mobile_profile
39
46
  @lifetime, @idle, @deadline = lifetime, idle_timeout, legacy_bridge_until
47
+ if remembered_profile
48
+ values = remembered_profile.values_at(:lifetime, :idle_timeout)
49
+ unless values.all? { |n| n.is_a?(Numeric) && n.finite? && n.between?(60, 90 * 86_400) } && values.last <= values.first
50
+ raise ArgumentError, "remembered session timeouts must be bounded and idle_timeout <= lifetime"
51
+ end
52
+ @remembered = remembered_profile.slice(:lifetime, :idle_timeout).freeze
53
+ end
40
54
  end
41
55
 
42
56
  def start(user:, method:, replacing: nil, **hints)
@@ -50,36 +64,72 @@ module AddAuth
50
64
  end
51
65
  end
52
66
 
53
- def authenticate(identifier:, password:, replacing: nil, **hints)
67
+ def authenticate(**arguments, &verify)
68
+ result = authenticate_result(**arguments, &verify)
69
+ result.grant if result.success?
70
+ end
71
+
72
+ def authenticate_result(identifier:, password:, replacing: nil, disclose_policy: false, **hints)
54
73
  @store.with_identifier(identifier: identifier, replacing: replacing) do |user|
74
+ @password_lifecycle&.prepare(user: user)
55
75
  verified = yield
56
- create_in_transaction(user: user, method: :password, replacing: replacing, **hints) if user && verified && verified.id == user.id
76
+ valid = user && verified && verified.id == user.id && verified.password_digest == user.password_digest
77
+ denial = @verified_denial&.call(user) if valid && disclose_policy
78
+ valid = @password_lifecycle.verified(user: user, password: password, valid: !!valid) if @password_lifecycle
79
+ grant = create_in_transaction(user: user, method: :password, replacing: replacing, **hints) if valid
80
+ if grant
81
+ Result.success(user: user, strategy: :password, session: grant.session, grant: grant)
82
+ else
83
+ Result.failure(reason: %i[unconfirmed locked disabled].include?(denial) ? denial : :invalid_credentials)
84
+ end
57
85
  end
58
86
  end
59
87
 
60
88
  # Runs inside the store's account transaction. When replacing a browser,
61
89
  # that session's account must also be locked on the same connection.
62
- def create_in_transaction(user:, method:, persist: nil, replacing: nil, proof: nil, **hints)
90
+ def create_in_transaction(user:, method:, persist: nil, replacing: nil, proof: nil, remember: false, transport: :browser, client_id: nil, **hints)
63
91
  return unless user && @eligible.call(user) == true
64
- raise ArgumentError, "unsupported proof" unless %w[password email_link passkey].include?(method.to_s)
92
+ supported_transport = transport == :browser || (transport == :mobile && @mobile&.client?(client_id))
93
+ return unless supported_transport
94
+ return if transport == :mobile && replacing
95
+ raise ArgumentError, "unsupported proof" unless %w[password email_link passkey external_identity].include?(method.to_s)
65
96
  return if @access_policy && !@access_policy.sign_in_allowed?(user, method)
66
97
  if method.to_s == "passkey"
67
98
  return unless proof.is_a?(StepUp::Evidence) && proof.strong? && proof.user_id == user.id &&
68
99
  proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300
69
100
  end
101
+ if method.to_s == "external_identity"
102
+ return unless @access_policy && proof.is_a?(ExternalIdentities::SessionProof) && proof.user_id == user.id &&
103
+ proof.verified_at <= @clock.now && proof.verified_at > @clock.now - 300 &&
104
+ @access_policy.external_credential_current?(user: user, id: proof.credential_id, version: proof.credential_version)
105
+ hints = hints.merge(authentication_external_id: proof.credential_id,
106
+ authentication_external_version: proof.credential_version)
107
+ end
70
108
  if user.respond_to?(:add_auth_policy_version)
71
109
  hints = hints.merge(authentication_policy_version: @access_policy ? @access_policy.version(user) : user.add_auth_policy_version,
72
110
  authentication_credential_id: proof&.credential_id, authentication_uv: proof&.user_verification || false)
73
111
  end
74
- raw = bearer
112
+ raw = (transport == :mobile) ? @mobile.bearer : bearer
75
113
  now = @clock.now
76
114
  if replacing
77
115
  prior = @store.find_for_user_in_transaction(user_id: replacing.user_id, session_id: replacing.id)
78
116
  @store.update(prior, revoked_at: now) if prior && !prior.revoked_at && prior.token_digest == replacing.token_digest
79
117
  end
80
118
  writer = persist || ->(**attributes) { @store.create(user: user, **attributes) }
119
+ duration = @lifetime
120
+ if @remembered
121
+ remembered = remember == true
122
+ duration = @remembered.fetch(:lifetime) if remembered
123
+ hints = hints.merge(remembered: remembered, idle_timeout: remembered ? @remembered.fetch(:idle_timeout) : @idle)
124
+ end
125
+ if transport == :mobile
126
+ duration = @mobile.lifetime
127
+ hints = hints.merge(transport: "mobile", client_id: client_id, mobile_idle_timeout: @mobile.idle_timeout)
128
+ hints[:remembered] = false if @remembered
129
+ end
81
130
  row = writer.call(token_digest: @digest.digest(raw), authenticated_with: method.to_s,
82
- authenticated_at: now, expires_at: now + @lifetime, last_seen_at: now, **hints)
131
+ authenticated_at: now, expires_at: now + duration, last_seen_at: now, **hints)
132
+ @on_sign_in&.call(user: user, session: row, at: now)
83
133
  Grant.new(session: row, bearer: raw)
84
134
  end
85
135
 
@@ -90,7 +140,7 @@ module AddAuth
90
140
  legacy = lookup.key?(:id)
91
141
  @store.with_session(**lookup) do |user, row|
92
142
  now = @clock.now
93
- next unless live?(user, row, now: now)
143
+ next unless transport_for(row) == "browser" && live?(user, row, now: now)
94
144
  if legacy
95
145
  next if row.token_digest || now >= @deadline
96
146
  raw = bearer
@@ -104,13 +154,25 @@ module AddAuth
104
154
  end
105
155
  end
106
156
 
157
+ # Only the exact Authorization bearer value enters this parser. Cookies,
158
+ # query parameters and legacy host API tokens are not alternate inputs.
159
+ def resume_mobile(bearer:)
160
+ return unless @mobile && bearer.is_a?(String) && MobileProfile::PATTERN.match?(bearer)
161
+ @store.with_session(digest: @digest.digest(bearer)) do |user, row|
162
+ now = @clock.now
163
+ next unless transport_for(row) == "mobile" && live?(user, row, now: now)
164
+ @store.update(row, last_seen_at: now) if row.last_seen_at <= now - 60
165
+ Grant.new(session: row)
166
+ end
167
+ end
168
+
107
169
  # A trusted cookie identifies what to retire, even if that account is now
108
170
  # ineligible. This snapshot is replacement input, never authentication.
109
171
  def replacement_for(signed_value:)
110
172
  lookup = cookie_lookup(signed_value)
111
173
  return unless lookup
112
174
  @store.with_session(**lookup) do |_user, row|
113
- row unless lookup.key?(:id) && row&.token_digest
175
+ row if transport_for(row) == "browser" && !(lookup.key?(:id) && row&.token_digest)
114
176
  end
115
177
  end
116
178
 
@@ -134,7 +196,7 @@ module AddAuth
134
196
  now = @clock.now
135
197
  candidates = @store.list_for_user(user_id: user.id, before: before&.to_i,
136
198
  excluding: current_session_id, limit: PAGE_SIZE + 1, now: now,
137
- active_after: now - @idle, legacy: !!(@deadline && now < @deadline))
199
+ active_after: now - [@idle, @remembered&.fetch(:idle_timeout) || @idle, @mobile&.idle_timeout || @idle].max, legacy: !!(@deadline && now < @deadline))
138
200
  cursor = candidates[PAGE_SIZE - 1].id if candidates.length > PAGE_SIZE
139
201
  rows = candidates.first(PAGE_SIZE)
140
202
  if before.nil? && current_session_id
@@ -147,7 +209,8 @@ module AddAuth
147
209
  Entry.new(id: row.id, current: row.id == current_session_id,
148
210
  authenticated_with: row.authenticated_with, authenticated_at: row.authenticated_at,
149
211
  created_at: row.created_at, last_seen_at: row.last_seen_at, expires_at: row.expires_at,
150
- ip_address: row.ip_address, user_agent: row.user_agent)
212
+ ip_address: row.ip_address, user_agent: row.user_agent,
213
+ transport: transport_for(row), client_id: row.respond_to?(:client_id) ? row.client_id : nil)
151
214
  end
152
215
  Page.new(entries: entries, next_cursor: cursor)
153
216
  end
@@ -169,8 +232,11 @@ module AddAuth
169
232
  def reauthenticate(user:, session:, purpose:, policy:)
170
233
  with_live_session(user: user, session: session) do |account, row, _now|
171
234
  next Result.failure(reason: :elevation_required) if @access_policy && !@access_policy.sign_in_allowed?(account, :password)
235
+ @password_lifecycle&.prepare(user: account)
172
236
  verified = yield account
173
- next Result.failure(reason: :invalid_credentials) unless verified && verified.id == account.id
237
+ valid = verified && verified.id == account.id && verified.password_digest == account.password_digest
238
+ valid = @password_lifecycle.verified(user: account, password: nil, valid: !!valid, rehash: false) if @password_lifecycle
239
+ next Result.failure(reason: :invalid_credentials) unless valid
174
240
  evidence = StepUp::Evidence.new(user_id: account.id, session_id: row.id,
175
241
  method: :password, verified_at: @clock.now, session_digest: row.token_digest,
176
242
  credential_version: policy.password_version(account))
@@ -202,7 +268,7 @@ module AddAuth
202
268
  row = current_in_transaction(user: user, session: session)
203
269
  return unless row && grant.is_a?(StepUp::Grant) && grant.valid_for?(user: user,
204
270
  user_id: user.id, session_id: row.id, session_digest: row.token_digest, purpose: grant.purpose, now: @clock.now)
205
- raw = bearer
271
+ raw = (transport_for(row) == "mobile") ? @mobile.bearer : bearer
206
272
  attributes = {token_digest: @digest.digest(raw), elevated_at: grant.verified_at,
207
273
  elevated_with: grant.method.to_s, elevation_purpose: grant.purpose.to_s,
208
274
  elevation_credential_id: grant.credential_id, elevation_uv: grant.user_verification,
@@ -232,7 +298,9 @@ module AddAuth
232
298
  now = @clock.now
233
299
  return Result.failure(reason: :elevation_required) unless row&.respond_to?(:elevation_version) && row.elevated_at &&
234
300
  row.elevation_purpose == purpose.to_s && row.elevation_expires_at && now < row.elevation_expires_at
235
- evidence = StepUp::Evidence.new(user_id: user.id, session_id: row.id,
301
+ evidence_class = (row.elevated_with == "external_identity") ? StepUp::ExternalEvidence : StepUp::Evidence
302
+ extra = (row.elevated_with == "external_identity") ? {purpose: purpose} : {}
303
+ evidence = evidence_class.send(:new, **extra, user_id: user.id, session_id: row.id,
236
304
  method: row.elevated_with, verified_at: row.elevated_at, session_digest: row.token_digest,
237
305
  credential_id: row.elevation_credential_id, user_verification: row.elevation_uv,
238
306
  credential_version: row.elevation_version)
@@ -277,10 +345,27 @@ module AddAuth
277
345
  return false unless user && row && @eligible.call(user) == true && !row.revoked_at
278
346
  return false if @access_policy && !@access_policy.session_allowed?(user, row)
279
347
  legacy = !row.token_digest && @deadline && now < @deadline
348
+ idle = @idle
349
+ if transport_for(row) == "mobile"
350
+ return false unless @mobile&.client?(row.client_id) && row.token_digest
351
+ stored_idle = row.mobile_idle_timeout
352
+ return false unless stored_idle.is_a?(Integer) && stored_idle.between?(60, 90 * 86_400)
353
+ idle = [stored_idle, @mobile.idle_timeout].min
354
+ return false unless row.authenticated_at && (row.authenticated_at + @mobile.lifetime > now)
355
+ elsif transport_for(row) != "browser"
356
+ return false
357
+ elsif @remembered && row.respond_to?(:remembered) && row.remembered == true
358
+ stored_idle = row.idle_timeout
359
+ return false unless stored_idle.is_a?(Numeric) && stored_idle.finite? && stored_idle.positive?
360
+ idle = [stored_idle, @remembered.fetch(:idle_timeout)].min
361
+ return false unless row.authenticated_at && row.authenticated_at + @remembered.fetch(:lifetime) > now
362
+ end
280
363
  (legacy || (row.expires_at && row.last_seen_at)) &&
281
- (!row.expires_at || row.expires_at > now) && (!row.last_seen_at || row.last_seen_at > now - @idle)
364
+ (!row.expires_at || row.expires_at > now) && (!row.last_seen_at || row.last_seen_at > now - idle)
282
365
  end
283
366
 
367
+ def transport_for(row) = row.respond_to?(:transport) ? row.transport : "browser"
368
+
284
369
  def bearer = "lk1:#{SecureRandom.urlsafe_base64(32)}"
285
370
  end
286
371
  end
@@ -5,7 +5,7 @@ module AddAuth
5
5
  # Evidence is issued by a trusted verifier, never deserialized from a client.
6
6
  # A grant retains that verifier's current policy and the proof's original age.
7
7
  class StepUp
8
- METHODS = %i[password email_link passkey].freeze
8
+ METHODS = %i[password email_link passkey external_identity].freeze
9
9
 
10
10
  class Evidence
11
11
  attr_reader :user_id, :session_id, :method, :verified_at, :credential_id,
@@ -28,6 +28,15 @@ module AddAuth
28
28
  def inspect = "#<AddAuth::Core::StepUp::Evidence [FILTERED]>"
29
29
  end
30
30
 
31
+ class ExternalEvidence < Evidence
32
+ attr_reader :purpose
33
+ def initialize(purpose:, **attributes)
34
+ @purpose = purpose.to_sym
35
+ super(**attributes)
36
+ end
37
+ private_class_method :new
38
+ end
39
+
31
40
  class Grant
32
41
  attr_reader :purpose, :expires_at
33
42
  def initialize(evidence:, purpose:, expires_at:, policy:)
@@ -117,6 +126,9 @@ module AddAuth
117
126
  return false if rule.require_passkey && !evidence.strong?
118
127
  return false unless evidence.session_digest.is_a?(String) && !evidence.session_digest.empty?
119
128
  return false if evidence.method == :passkey && !evidence.strong?
129
+ if evidence.method == :external_identity
130
+ return false unless evidence.is_a?(ExternalEvidence) && evidence.purpose == purpose.to_sym
131
+ end
120
132
  if evidence.method == :password
121
133
  return false unless evidence.credential_version.is_a?(String) && !evidence.credential_version.empty?
122
134
  return false unless user.respond_to?(:password_digest) && password_version(user) == evidence.credential_version
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+ require "pathname"
5
+ require "add_auth/core/migration/readiness"
6
+
7
+ module AddAuth
8
+ module Migration
9
+ # Reads a bounded set of source files as data. Never require/load/eval a host.
10
+ class StaticInventory
11
+ MAX_FILES = 2000
12
+ MAX_BYTES = 1_048_576
13
+ TOTAL_BYTES = 16 * 1_048_576
14
+ PATTERNS = %w[app/models/**/*.rb app/controllers/**/*.rb app/services/**/*.rb app/jobs/**/*.rb app/workers/**/*.rb app/mailers/**/*.rb app/helpers/**/*.rb lib/**/*.rb lib/tasks/**/*.rake config/initializers/**/*.rb config/routes.rb db/schema.rb spec/factories/**/*.rb spec/support/**/*.rb].freeze
15
+ SURFACES = {
16
+ devise: %w[Devise devise devise_for devise_scope], warden: %w[warden Warden],
17
+ guards: %w[authenticate_user! current_user user_signed_in? authenticated],
18
+ passwords: %w[encrypted_password password_digest valid_password? password=],
19
+ tokens: %w[authentication_token MobileAuthToken reset_password_token confirmation_token unlock_token],
20
+ providers: %w[OmniAuth omniauth omniauth_callbacks],
21
+ eligibility: %w[active_for_authentication? inactive_message locked_at confirmed_at disabled_at]
22
+ }.freeze
23
+
24
+ def initialize(root:)
25
+ @root = File.realpath(root)
26
+ end
27
+
28
+ def call
29
+ @complete = true
30
+ @remaining_bytes = TOTAL_BYTES
31
+ paths = PATTERNS.flat_map { |pattern| Dir.glob(File.join(@root, pattern)) }.uniq.sort
32
+ @complete = false if paths.length > MAX_FILES
33
+ sources = paths.first(MAX_FILES).filter_map do |path|
34
+ source = read(path)
35
+ next unless source
36
+ lexical = Ripper.lex(source)
37
+ tokens = lexical.reject { |(_, kind, _, _)| %i[on_comment on_tstring_content].include?(kind) }
38
+ literal_keys = lexical.filter_map { |(_, kind, value, _)| value if kind == :on_tstring_content && SURFACES.values.flatten.include?(value) }
39
+ tree = Ripper.sexp(source)
40
+ @complete = false unless tree
41
+ [relative(path), {tokens: tokens.map { |token| token[2] }, literal_keys: literal_keys, tree: tree}]
42
+ end.to_h
43
+ calls = sources.values.flat_map { |source| devise_calls(source[:tree]) }
44
+ names = calls.flatten
45
+ unknown = names.count { |name| !Core::Migration::Readiness::MODULES.include?(name) }
46
+ @complete = false if names.include?(nil)
47
+ lock = read(File.join(@root, "Gemfile.lock")) || ""
48
+ versions = %w[devise rails ruby bcrypt].to_h do |name|
49
+ version = if name == "ruby"
50
+ lock[/^ ruby (\d+\.\d+\.\d+)/, 1]
51
+ else
52
+ lock[/^ #{Regexp.escape(name)} \((\d+\.\d+(?:\.\d+)*(?:[.a-z0-9-]*))\)/, 1]
53
+ end
54
+ [name.to_sym, version]
55
+ end
56
+ extensions = Core::Migration::Readiness::EXTENSIONS.select { |name| lock.match?(/^ #{Regexp.escape(name)} \(/) }
57
+ targets = Core::Migration::Readiness::TARGETS.select { |path| sources.key?(path) }
58
+ user = sources.dig("app/models/user.rb", :tokens) || []
59
+ facts = {mode: "static", complete: @complete, versions: versions,
60
+ modules: (names.compact & Core::Migration::Readiness::MODULES).sort,
61
+ unknown_modules: unknown, extensions: extensions, scope_count: calls.length,
62
+ rails_contract: targets.length == Core::Migration::Readiness::TARGETS.length && user.include?("has_secure_password") && user.include?("normalizes"),
63
+ target_conflicts: targets, files_inspected: sources.length,
64
+ surfaces: SURFACES.transform_values { |terms| sources.filter_map { |path, source| path if ((source[:tokens] + source[:literal_keys]) & terms).any? } }}
65
+ Core::Migration::Readiness.new.call(facts)
66
+ end
67
+
68
+ private
69
+
70
+ def read(path)
71
+ # Reject symlinks at any depth, even those pointing to another host file.
72
+ current = Pathname.new(path)
73
+ while current.to_s != @root
74
+ return incomplete if current.symlink?
75
+ parent = current.parent
76
+ return incomplete if parent == current
77
+ current = parent
78
+ end
79
+ return incomplete unless File.file?(path)
80
+ return incomplete if @remaining_bytes <= 0
81
+ File.open(path, "rb") do |file|
82
+ limit = [MAX_BYTES, @remaining_bytes].min
83
+ source = file.read(limit + 1)
84
+ @remaining_bytes -= source.bytesize
85
+ return incomplete if source.bytesize > limit
86
+ source.force_encoding(Encoding::UTF_8)
87
+ return incomplete unless source.valid_encoding?
88
+ source
89
+ end
90
+ rescue SystemCallError
91
+ incomplete
92
+ end
93
+
94
+ def incomplete
95
+ @complete = false
96
+ nil
97
+ end
98
+
99
+ def relative(path) = Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
100
+
101
+ def devise_calls(node)
102
+ return [] unless node.is_a?(Array)
103
+ arguments = if node[0] == :command && node.dig(1, 1) == "devise"
104
+ node[2]
105
+ elsif node[0] == :method_add_arg && node.dig(1, 0) == :fcall && node.dig(1, 1, 1) == "devise"
106
+ node.dig(2, 1)
107
+ end
108
+ if arguments
109
+ args = (arguments[0] == :args_add_block) ? arguments[1] : []
110
+ return [args.reject { |arg| arg[0] == :bare_assoc_hash }.map { |arg| (arg[0] == :symbol_literal) ? arg.dig(1, 1, 1) : nil }]
111
+ end
112
+ node.flat_map { |child| child.is_a?(Array) ? devise_calls(child) : [] }
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AddAuth
4
+ module Rails
5
+ module AccountPasswordEntry
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ protect_from_forgery with: :exception
10
+ prepend_before_action :add_auth_account_password_entry, only: %i[new create edit update]
11
+ end
12
+
13
+ private
14
+
15
+ def add_auth_account_password_entry
16
+ return unless AddAuth.configuration.lifecycle.enabled
17
+ verify_authenticity_token unless request.get? || request.head?
18
+ response.set_header("Cache-Control", "no-store")
19
+ response.set_header("Referrer-Policy", "no-referrer")
20
+ redirect_to "/account/requests/reset_password", status: request.get? ? :found : :see_other
21
+ end
22
+ end
23
+ end
24
+ end
@@ -27,7 +27,7 @@ module AddAuth
27
27
  end
28
28
 
29
29
  def add_auth_session_hints
30
- {user_agent: request.user_agent.to_s.truncate_bytes(512), ip_address: request.remote_ip}
30
+ {user_agent: request.user_agent.to_s.truncate_bytes(512), ip_address: request.remote_ip, remember: params[:remember] == "1"}
31
31
  end
32
32
 
33
33
  def add_auth_replacement_session
@@ -16,7 +16,7 @@ module AddAuth
16
16
  add_auth_accept(grant)
17
17
  redirect_to destination, status: :see_other
18
18
  else
19
- @error = "Email or password is incorrect. Try again or request a sign-in link."
19
+ @error = "Email or password is incorrect. Try again."
20
20
  page("form", status: :unprocessable_entity)
21
21
  end
22
22
  end
@@ -44,6 +44,11 @@ module AddAuth
44
44
 
45
45
  def page(partial, status: :ok)
46
46
  @page_partial = authentication_partial(partial)
47
+ # Turbo carries the form's stream Accept header across a 303. Prefer
48
+ # its HTML option on the destination GET so navigation commits the URL.
49
+ if (request.get? || request.head?) && request.accepts.include?(Mime[:html])
50
+ return render "add_auth/sign_ins/show", layout: "add_auth/authentication", formats: [:html], content_type: "text/html", status: status
51
+ end
47
52
  respond_to do |format|
48
53
  format.html { render "add_auth/sign_ins/show", layout: "add_auth/authentication", status: status }
49
54
  format.turbo_stream { render turbo_stream: turbo_stream.update("add_auth-content", partial: @page_partial), status: status }
@@ -33,6 +33,7 @@ module AddAuth
33
33
  end
34
34
  check("Include AddAuth::Rails::UserLifecycle in User") { ::User < UserLifecycle }
35
35
  check("Set passwords_enabled to true or false") { [true, false].include?(config.passwords_enabled) }
36
+ check("Set turbo_enabled to true or false") { [true, false].include?(config.turbo_enabled) }
36
37
  if config.passwords_enabled || defined?(::SessionsController)
37
38
  check("Install the shared password entry in SessionsController") { ::SessionsController < PasswordEntry }
38
39
  end
@@ -63,6 +64,25 @@ module AddAuth
63
64
  end
64
65
  end
65
66
  end
67
+ if config.mobile.enabled
68
+ columns(::Session, %w[transport client_id mobile_idle_timeout], "mobile session")
69
+ check("Configure explicit bounded mobile timeouts and registered client identifiers") { Runtime.mobile_profile }
70
+ check("Install mobile session routes") { ::Rails.application.routes.recognize_path("/mobile/session", method: :post) }
71
+ check("Configure mobile callbacks and Apple providers as explicit maps") do
72
+ config.mobile.callbacks.is_a?(Hash) && config.mobile.apple_providers.is_a?(Hash)
73
+ end
74
+ if config.mobile.callbacks.is_a?(Hash) && config.mobile.callbacks.any?
75
+ check("Enable reviewed provider integration before mobile handoffs") { config.external_identities.enabled }
76
+ columns(defined?(::AddAuthMobileHandoff) && ::AddAuthMobileHandoff,
77
+ %w[user_id external_digest digest client_id callback state challenge_digest credential_id credential_version policy_version authenticated_at issued_at expires_at consumed_at], "mobile handoff")
78
+ unique_index(::AddAuthMobileHandoff, "digest") if defined?(::AddAuthMobileHandoff)
79
+ unique_index(::AddAuthMobileHandoff, "external_digest") if defined?(::AddAuthMobileHandoff)
80
+ check("Use one database pool for mobile handoffs, accounts and sessions") { Runtime.mobile_handoffs }
81
+ end
82
+ if config.mobile.apple_providers.is_a?(Hash) && config.mobile.apple_providers.any?
83
+ check("Configure the optional native Apple library and exact client mapping") { Runtime.native_apple }
84
+ end
85
+ end
66
86
  if config.email_link.enabled
67
87
  check("Enable session_upgrade before email_link") { config.session.enabled }
68
88
  check("Configure base_url as a fixed HTTPS origin (HTTP only outside production)") { Runtime.sign_in_url("validation") }
@@ -75,7 +95,59 @@ module AddAuth
75
95
  columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken, ["browser_digest"], "email browser binding")
76
96
  end
77
97
  end
78
- if config.email_link.enabled || config.notifications.enabled
98
+ if config.external_identities.enabled
99
+ check("Enable session and step-up prerequisites for provider sign-in") { config.session.enabled && config.step_up.enabled }
100
+ check("Declare current password verifier support metadata") { config.current_password_support.respond_to?(:available?) }
101
+ if config.external_identities.providers.any?
102
+ require "add_auth/rails/provider_libraries/request_protection"
103
+ check("Configure a supported OmniAuth request validator") { !!ProviderLibraries::RequestProtection.validator }
104
+ end
105
+ check("Register at least one reviewed provider profile") { config.external_identities.configurations.any? }
106
+ columns(defined?(::AddAuthExternalIdentity) && ::AddAuthExternalIdentity,
107
+ %w[user_id namespace provider_id issuer audience subject provenance credential_version linked_at revoked_at invalidated_at], "external identity")
108
+ columns(defined?(::AddAuthExternalTransaction) && ::AddAuthExternalTransaction,
109
+ %w[digest browser_digest provider_id issuer audience purpose user_id session_id session_digest policy_version issued_at expires_at consumed_at enrollment_payload], "external transaction")
110
+ columns(::Session, %w[authentication_external_id authentication_external_version], "external session evidence")
111
+ unique_index(::AddAuthExternalIdentity, "namespace") if defined?(::AddAuthExternalIdentity)
112
+ unique_index(::AddAuthExternalTransaction, "digest") if defined?(::AddAuthExternalTransaction)
113
+ check("Use one database pool for provider, account and session persistence") { Runtime.external_identities }
114
+ config.external_identities.providers.each do |provider|
115
+ check("Install the configured provider callback and preparation routes") do
116
+ ::Rails.application.routes.recognize_path("/sign-in/providers/#{provider.middleware_name}", method: :post) &&
117
+ ::Rails.application.routes.recognize_path("/auth/#{provider.middleware_name}/callback", method: provider.apple_form_post? ? :post : :get)
118
+ end
119
+ end
120
+ check("Install provider account management routes") { ::Rails.application.routes.recognize_path("/account/external-identities", method: :get) }
121
+ end
122
+ if config.lifecycle.enabled
123
+ check("Set explicit confirmation and unconfirmed-reset policies") { Runtime.account_policy }
124
+ if config.lifecycle.confirmation_required == false
125
+ columns(::User, %w[add_auth_provisioned_at], "optional confirmation provisioning")
126
+ check("Enable password authentication for optional signup confirmation") { config.passwords_enabled }
127
+ end
128
+ if defined?(::PasswordsController)
129
+ check("Retire stock password reset entry points through the shared account flow") { ::PasswordsController < AccountPasswordEntry }
130
+ end
131
+ columns(::Session, %w[remembered idle_timeout], "remembered browser sessions")
132
+ check("Enable session, step-up and notification prerequisites for account lifecycle") { config.session.enabled && config.step_up.enabled && config.notifications.enabled }
133
+ columns(::User, %w[confirmed_at unconfirmed_email locked_at failed_attempts disabled_at deleted_at add_auth_manual_lock add_auth_locked_until add_auth_authority], "account lifecycle")
134
+ columns(defined?(::AddAuthAccountToken) && ::AddAuthAccountToken,
135
+ (TOKEN_COLUMNS - %w[identifier_digest]) + %w[address_digest account_version], "account proofs")
136
+ columns(defined?(::AddAuthAddressClaim) && ::AddAuthAddressClaim, %w[user_id digest state], "address claims")
137
+ if defined?(::AddAuthAccountToken)
138
+ unique_index(::AddAuthAccountToken, "digest")
139
+ unique_index(::AddAuthAccountToken, "request_id")
140
+ end
141
+ unique_index(::AddAuthAddressClaim, "digest") if defined?(::AddAuthAddressClaim)
142
+ check("Configure account policy, profile mapping and local transaction provisioning callbacks") do
143
+ %i[eligible password_policy profile_attributes provision deletion_allowed delete_account].all? { |name| config.lifecycle.public_send(name).respond_to?(:call) }
144
+ end
145
+ check("Configure account proof delivery, lock policy and shared account storage") { Runtime.accounts && Runtime.password_lifecycle && Runtime.account_proof_url("validation", purpose: "confirm") }
146
+ %w[/account/sign-up /account/requests/confirm /account/email /account/password].each do |path|
147
+ check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
148
+ end
149
+ end
150
+ if config.email_link.enabled || config.notifications.enabled || config.lifecycle.enabled
79
151
  check("Configure mail_from") { config.mail_from.is_a?(String) && !config.mail_from.strip.empty? }
80
152
  check("Enable Action Mailer delivery") { ActionMailer::Base.perform_deliveries && ActionMailer::Base.raise_delivery_errors }
81
153
  if ::Rails.env.production?
@@ -83,7 +155,7 @@ module AddAuth
83
155
  check("Configure production mail delivery") { !%i[test file].include?(ActionMailer::Base.delivery_method) }
84
156
  end
85
157
  end
86
- maintenance_needed = config.passkeys.enabled || config.email_link.enabled || config.notifications.enabled || config.maintenance.session_retention
158
+ maintenance_needed = config.passkeys.enabled || config.email_link.enabled || config.notifications.enabled || config.lifecycle.enabled || config.external_identities.enabled || config.mobile.enabled || config.maintenance.session_retention
87
159
  if ::Rails.env.production? && maintenance_needed
88
160
  check("Schedule add_auth:deliver_pending every minute; no successful cleanup in the last two minutes") { Runtime.maintenance_current? }
89
161
  end
@@ -112,17 +184,19 @@ module AddAuth
112
184
  check("Enable security notification delivery errors") { ::AddAuth::SecurityMailer.raise_delivery_errors }
113
185
  end
114
186
  if config.step_up.enabled
115
- check("Enable session and email prerequisites for step_up") { config.session.enabled && config.email_link.enabled }
187
+ check("Enable session prerequisites for step_up") { config.session.enabled }
116
188
  columns(::Session, %w[elevation_version elevation_expires_at], "reauthentication")
117
- columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken,
118
- %w[browser_digest session_id session_digest authentication_purpose], "reauthentication")
189
+ if config.email_link.enabled
190
+ columns(defined?(::AddAuthSignInToken) && ::AddAuthSignInToken,
191
+ %w[browser_digest session_id session_digest authentication_purpose], "reauthentication")
192
+ end
119
193
  check("Declare valid step-up purposes and safe return destinations") { Runtime.step_up_purposes.any? && Runtime.step_up_policy }
120
194
  %w[/reauthenticate /reauthenticate/link].each do |path|
121
195
  check("Install route #{path}") { ::Rails.application.routes.recognize_path(path, method: :get) }
122
196
  end
123
197
  end
124
198
  if config.challenge_on.any?
125
- check("Unknown challenge actions") { (config.challenge_on - %i[sign_in email_link reauthenticate passkey_enrollment]).empty? }
199
+ check("Unknown challenge actions (email proof pages cannot load third-party challenges)") { (config.challenge_on - %i[sign_in email_link reauthenticate passkey_enrollment register account_request provider]).empty? }
126
200
  check("Configure a challenge provider before setting challenge_on") { !config.challenge.is_a?(Core::Challenge::Null) }
127
201
  if ::Rails.env.production?
128
202
  check("Configure challenge hostname restrictions") { config.challenge.allowed_hostnames&.any? }
@@ -9,7 +9,7 @@ module AddAuth
9
9
  class Ejection
10
10
  MANIFEST = "config/add_auth-ejections.json"
11
11
  VIEW_GROUPS = {"email_link" => %w[sign_ins], "sessions" => %w[sessions],
12
- "step_up" => %w[reauthentications], "passkeys" => %w[passkeys recoveries]}.freeze
12
+ "step_up" => %w[reauthentications], "passkeys" => %w[passkeys recoveries], "accounts" => %w[accounts], "providers" => %w[provider_sign_ins external_identities]}.freeze
13
13
 
14
14
  def initialize(host_root:, engine_root: File.expand_path("../../..", __dir__))
15
15
  @host, @engine = host_root.to_s, engine_root.to_s
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "turbo-rails"
4
4
  require "add_auth/rails/authentication_pages"
5
+ require "add_auth/rails/provider_libraries/callback_route"
5
6
 
6
7
  module AddAuth
7
8
  module Rails
@@ -10,7 +11,7 @@ module AddAuth
10
11
  engine_name "add_auth"
11
12
 
12
13
  initializer "add_auth.filter_parameters" do |app|
13
- app.config.filter_parameters += [:token, :password, :delivery_payload, :token_digest, :encrypted_identifier, :browser_digest, :session_digest, :elevation_version, :add_auth_browser, :credential, :transaction, :challenge, :external_id, :public_key, :"cf-turnstile-response", :"g-recaptcha-response"]
14
+ app.config.filter_parameters += [:token, :password, :enrollment_payload, :code, :state, :nonce, :id_token, :access_token, :refresh_token, :client_secret, :authorization_code, :raw_info, :id_info, :delivery_payload, :token_digest, :encrypted_identifier, :browser_digest, :session_digest, :elevation_version, :add_auth_browser, :credential, :transaction, :challenge, :external_id, :public_key, :"cf-turnstile-response", :"g-recaptcha-response"]
14
15
  end
15
16
 
16
17
  config.to_prepare do
@@ -21,6 +22,10 @@ module AddAuth
21
22
  if AddAuth.configuration.passwords_enabled || defined?(::SessionsController)
22
23
  ::SessionsController.include AddAuth::Rails::PasswordEntry
23
24
  end
25
+ if defined?(::PasswordsController)
26
+ require "add_auth/rails/account_password_entry"
27
+ ::PasswordsController.include AddAuth::Rails::AccountPasswordEntry
28
+ end
24
29
  end
25
30
  end
26
31