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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +64 -17
- data/ROADMAP.md +98 -43
- data/SECURITY.md +8 -0
- data/app/controllers/add_auth/accounts_controller.rb +146 -0
- data/app/controllers/add_auth/assets_controller.rb +2 -1
- data/app/controllers/add_auth/external_identities_controller.rb +56 -0
- data/app/controllers/add_auth/mobile_sessions_controller.rb +120 -0
- data/app/controllers/add_auth/provider_sign_ins_controller.rb +248 -0
- data/app/controllers/add_auth/reauthentications_controller.rb +5 -0
- data/app/helpers/add_auth/sign_ins_helper.rb +2 -1
- data/app/jobs/add_auth/account_delivery_job.rb +14 -0
- data/app/jobs/add_auth/account_request_job.rb +17 -0
- data/app/mailers/add_auth/account_mailer.rb +12 -0
- data/app/views/add_auth/account_mailer/link.text.erb +7 -0
- data/app/views/add_auth/accounts/_check_email.html.erb +5 -0
- data/app/views/add_auth/accounts/_delete.html.erb +6 -0
- data/app/views/add_auth/accounts/_email.html.erb +12 -0
- data/app/views/add_auth/accounts/_invalid_proof.html.erb +5 -0
- data/app/views/add_auth/accounts/_password.html.erb +12 -0
- data/app/views/add_auth/accounts/_proof.html.erb +16 -0
- data/app/views/add_auth/accounts/_register.html.erb +17 -0
- data/app/views/add_auth/accounts/_request.html.erb +17 -0
- data/app/views/add_auth/accounts/_unavailable.html.erb +3 -0
- data/app/views/add_auth/external_identities/_index.html.erb +22 -0
- data/app/views/add_auth/external_identities/_unavailable.html.erb +3 -0
- data/app/views/add_auth/passkeys/_controls.html.erb +3 -1
- data/app/views/add_auth/provider_sign_ins/_enrollment.html.erb +12 -0
- data/app/views/add_auth/provider_sign_ins/_failure.html.erb +3 -0
- data/app/views/add_auth/provider_sign_ins/_prepare.html.erb +7 -0
- data/app/views/add_auth/reauthentications/_form.html.erb +9 -0
- data/app/views/add_auth/sessions/_list.html.erb +1 -1
- data/app/views/add_auth/sessions/index.html.erb +7 -0
- data/app/views/add_auth/sign_ins/_confirmation.html.erb +1 -0
- data/app/views/add_auth/sign_ins/_form.html.erb +17 -0
- data/app/views/add_auth/sign_ins/_remember.html.erb +6 -0
- data/app/views/layouts/add_auth/authentication.html.erb +4 -3
- data/exe/add_auth-preflight +26 -0
- data/lib/add_auth/configuration.rb +78 -4
- data/lib/add_auth/core/access_policy.rb +11 -1
- data/lib/add_auth/core/account_lifecycle.rb +295 -0
- data/lib/add_auth/core/account_policy.rb +68 -0
- data/lib/add_auth/core/external_identities/evidence.rb +98 -0
- data/lib/add_auth/core/external_identities.rb +278 -0
- data/lib/add_auth/core/intake.rb +6 -1
- data/lib/add_auth/core/maintenance.rb +5 -3
- data/lib/add_auth/core/migration/account_adoption.rb +79 -0
- data/lib/add_auth/core/migration/readiness.rb +41 -0
- data/lib/add_auth/core/mobile_authentication.rb +35 -0
- data/lib/add_auth/core/mobile_handoffs.rb +83 -0
- data/lib/add_auth/core/mobile_profile.rb +46 -0
- data/lib/add_auth/core/mobile_response.rb +12 -0
- data/lib/add_auth/core/native_authentication.rb +77 -0
- data/lib/add_auth/core/passwords/bcrypt_support.rb +28 -0
- data/lib/add_auth/core/passwords/credential.rb +49 -0
- data/lib/add_auth/core/passwords/legacy_bcrypt.rb +43 -0
- data/lib/add_auth/core/passwords/lifecycle.rb +60 -0
- data/lib/add_auth/core/remaining_factors.rb +24 -0
- data/lib/add_auth/core/sessions.rb +102 -17
- data/lib/add_auth/core/step_up.rb +13 -1
- data/lib/add_auth/migration/static_inventory.rb +116 -0
- data/lib/add_auth/rails/account_password_entry.rb +24 -0
- data/lib/add_auth/rails/authentication.rb +1 -1
- data/lib/add_auth/rails/authentication_pages.rb +6 -1
- data/lib/add_auth/rails/doctor.rb +80 -6
- data/lib/add_auth/rails/ejection.rb +1 -1
- data/lib/add_auth/rails/engine.rb +6 -1
- data/lib/add_auth/rails/migration/account_store.rb +41 -0
- data/lib/add_auth/rails/migration/effective_inventory.rb +162 -0
- data/lib/add_auth/rails/migration/key_type.rb +23 -0
- data/lib/add_auth/rails/migration/source_bridge.rb +53 -0
- data/lib/add_auth/rails/password_adoption.rb +37 -0
- data/lib/add_auth/rails/provider_enrollment.rb +61 -0
- data/lib/add_auth/rails/provider_libraries/apple_form_post_correlation.rb +127 -0
- data/lib/add_auth/rails/provider_libraries/apple_jwks.rb +68 -0
- data/lib/add_auth/rails/provider_libraries/apple_native.rb +55 -0
- data/lib/add_auth/rails/provider_libraries/callback_route.rb +16 -0
- data/lib/add_auth/rails/provider_libraries/omniauth.rb +136 -0
- data/lib/add_auth/rails/provider_libraries/omniauth_correlation.rb +107 -0
- data/lib/add_auth/rails/provider_libraries/openid_connect.rb +62 -0
- data/lib/add_auth/rails/provider_libraries/request_protection.rb +43 -0
- data/lib/add_auth/rails/runtime.rb +179 -8
- data/lib/add_auth/rails/stores/account_credentials.rb +44 -0
- data/lib/add_auth/rails/stores/account_lock.rb +16 -7
- data/lib/add_auth/rails/stores/account_tokens.rb +104 -0
- data/lib/add_auth/rails/stores/authority.rb +28 -0
- data/lib/add_auth/rails/stores/commit_dispatch.rb +32 -0
- data/lib/add_auth/rails/stores/external_identities.rb +91 -0
- data/lib/add_auth/rails/stores/maintenance.rb +1 -1
- data/lib/add_auth/rails/stores/mobile_handoffs.rb +53 -0
- data/lib/add_auth/rails/stores/security_events.rb +3 -6
- data/lib/add_auth/rails/user_lifecycle.rb +8 -10
- data/lib/add_auth/result.rb +1 -0
- data/lib/add_auth/version.rb +1 -1
- data/lib/add_auth.rb +4 -0
- data/lib/generators/add_auth/accounts/accounts_generator.rb +56 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_accounts.rb.tt +49 -0
- data/lib/generators/add_auth/accounts/templates/add_add_auth_provisioning.rb.tt +9 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_account_token.rb +6 -0
- data/lib/generators/add_auth/accounts/templates/add_auth_address_claim.rb +6 -0
- data/lib/generators/add_auth/devise_accounts/devise_accounts_generator.rb +23 -0
- data/lib/generators/add_auth/devise_accounts/templates/prepare_add_auth_devise_accounts.rb.tt +17 -0
- data/lib/generators/add_auth/email_link/email_link_generator.rb +4 -1
- data/lib/generators/add_auth/email_link/templates/add_auth_challenge.js +1 -0
- data/lib/generators/add_auth/email_tokens/templates/create_add_auth_sign_in_tokens.rb.tt +3 -1
- data/lib/generators/add_auth/external_identities/external_identities_generator.rb +52 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_enrollment.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_add_auth_external_identity_validity.rb.tt +5 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_identity.rb +4 -0
- data/lib/generators/add_auth/external_identities/templates/add_auth_external_transaction.rb +6 -0
- data/lib/generators/add_auth/external_identities/templates/create_add_auth_external_identities.rb.tt +50 -0
- data/lib/generators/add_auth/external_identities/templates/providers.rb +51 -0
- data/lib/generators/add_auth/feature_configuration.rb +21 -0
- data/lib/generators/add_auth/install/templates/initializer.rb +154 -46
- data/lib/generators/add_auth/javascript/templates/passkey.js +8 -5
- data/lib/generators/add_auth/mobile_sessions/mobile_sessions_generator.rb +48 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_add_auth_mobile_sessions.rb.tt +9 -0
- data/lib/generators/add_auth/mobile_sessions/templates/add_auth_mobile_handoff.rb +5 -0
- data/lib/generators/add_auth/mobile_sessions/templates/create_add_auth_mobile_handoffs.rb.tt +27 -0
- data/lib/generators/add_auth/mobile_sessions/templates/mobile.rb +8 -0
- data/lib/generators/add_auth/notifications/notifications_generator.rb +3 -4
- data/lib/generators/add_auth/notifications/templates/create_add_auth_security_events.rb.tt +3 -1
- data/lib/generators/add_auth/passkeys/passkeys_generator.rb +3 -21
- data/lib/generators/add_auth/passkeys/templates/add_add_auth_passkeys.rb.tt +7 -3
- data/lib/generators/add_auth/session_upgrade/session_upgrade_generator.rb +3 -1
- data/lib/generators/add_auth/step_up/step_up_generator.rb +5 -5
- data/lib/generators/add_auth/step_up/templates/add_add_auth_reauthentication.rb.tt +3 -1
- data/lib/tasks/add_auth.rake +18 -1
- 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(
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
|
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.
|
|
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
|
|
187
|
+
check("Enable session prerequisites for step_up") { config.session.enabled }
|
|
116
188
|
columns(::Session, %w[elevation_version elevation_expires_at], "reauthentication")
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|