standard_id 0.43.2 → 0.45.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 +72 -0
- data/README.md +148 -3
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +3 -18
- data/app/controllers/concerns/standard_id/social_authentication.rb +316 -8
- data/app/controllers/concerns/standard_id/web_authentication.rb +6 -1
- data/app/controllers/standard_id/api/authorization_controller.rb +11 -4
- data/app/controllers/standard_id/api/base_controller.rb +16 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +61 -15
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +51 -3
- data/app/controllers/standard_id/web/consent_controller.rb +5 -1
- data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
- data/app/controllers/standard_id/web/signup_controller.rb +6 -1
- data/app/models/standard_id/social_identity.rb +30 -0
- data/db/migrate/20261002000000_create_standard_id_social_identities.rb +44 -0
- data/db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb +17 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +21 -4
- data/lib/standard_id/account_cleanup.rb +62 -0
- data/lib/standard_id/api/token_manager.rb +18 -2
- data/lib/standard_id/auth_lineage.rb +75 -0
- data/lib/standard_id/config/callable_validator.rb +5 -0
- data/lib/standard_id/config/schema.rb +18 -0
- data/lib/standard_id/errors.rb +68 -2
- data/lib/standard_id/events/definitions.rb +6 -1
- data/lib/standard_id/events/subscribers/logging_subscriber.rb +1 -0
- data/lib/standard_id/login_method_policy.rb +108 -0
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +2 -1
- data/lib/standard_id/oauth/authorization_code_flow.rb +6 -0
- data/lib/standard_id/oauth/authorization_flow.rb +6 -2
- data/lib/standard_id/oauth/password_flow.rb +13 -2
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
- data/lib/standard_id/oauth/refresh_token_flow.rb +32 -0
- data/lib/standard_id/oauth/social_flow.rb +4 -0
- data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
- data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
- data/lib/standard_id/providers/base.rb +34 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +50 -3
- data/lib/standard_id/web/token_manager.rb +8 -3
- data/lib/standard_id.rb +3 -0
- metadata +7 -1
|
@@ -31,6 +31,27 @@ module StandardId
|
|
|
31
31
|
provider.get_user_info(**resolved_params.compact)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Resolves the account for a social login, in this order:
|
|
35
|
+
#
|
|
36
|
+
# 1. (provider, sub) matches a StandardId::SocialIdentity → that account.
|
|
37
|
+
# The provider's stable subject id is authoritative; the email it
|
|
38
|
+
# reports is not consulted.
|
|
39
|
+
# 2. The email matches an existing EmailIdentifier → link to that account,
|
|
40
|
+
# but only when
|
|
41
|
+
# - the link_strategy allows it (validate_social_link!; under :strict a
|
|
42
|
+
# provider that is trusted_for_linking? may link across providers
|
|
43
|
+
# to a verified identifier),
|
|
44
|
+
# - the identifier is not already linked to a DIFFERENT sub from this
|
|
45
|
+
# provider (possible takeover), and
|
|
46
|
+
# - the provider reports the email as verified. Without that, the
|
|
47
|
+
# token proves nothing about who owns the address — under
|
|
48
|
+
# :trust_provider, or for a pre-provider-tracking identifier, any
|
|
49
|
+
# provider token for the address would otherwise take the account.
|
|
50
|
+
# A successful link stores the sub, so step 1 matches next time.
|
|
51
|
+
# 3. Otherwise create a new account (unchanged) and store the sub.
|
|
52
|
+
#
|
|
53
|
+
# Each refusal raises StandardId::SocialLinkError (with a `reason`) after
|
|
54
|
+
# emitting SOCIAL_LINK_BLOCKED; a duplicate account is never created.
|
|
34
55
|
def find_or_create_account_from_social(raw_social_info)
|
|
35
56
|
social_info = raw_social_info.to_h.with_indifferent_access
|
|
36
57
|
email = social_info[:email]
|
|
@@ -38,12 +59,22 @@ module StandardId
|
|
|
38
59
|
|
|
39
60
|
emit_social_user_info_fetched(provider, social_info, email)
|
|
40
61
|
|
|
62
|
+
subject = social_subject(social_info)
|
|
63
|
+
social_identity = find_social_identity(subject)
|
|
64
|
+
if social_identity
|
|
65
|
+
emit_social_account_linked(social_identity.account, provider, social_identity.identifier)
|
|
66
|
+
return social_identity.account
|
|
67
|
+
end
|
|
68
|
+
|
|
41
69
|
identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: email)
|
|
42
70
|
|
|
43
71
|
if identifier.present?
|
|
44
72
|
validate_social_link!(identifier, provider)
|
|
45
|
-
|
|
46
|
-
|
|
73
|
+
validate_social_subject!(identifier, provider, subject)
|
|
74
|
+
validate_social_email_verified!(identifier, provider, social_info)
|
|
75
|
+
# SOCIAL_ACCOUNT_LINKED is published by commit_social_link!, once the
|
|
76
|
+
# link has been written — never for a staged link that is dropped.
|
|
77
|
+
stage_social_link!(identifier, subject, backfill_provider: identifier.provider.nil?, emit_linked: true)
|
|
47
78
|
identifier.account
|
|
48
79
|
else
|
|
49
80
|
account = build_account_from_social(social_info)
|
|
@@ -52,7 +83,8 @@ module StandardId
|
|
|
52
83
|
value: email,
|
|
53
84
|
provider: provider.provider_name
|
|
54
85
|
)
|
|
55
|
-
identifier.verify! if identifier.respond_to?(:verify!) &&
|
|
86
|
+
identifier.verify! if identifier.respond_to?(:verify!) && social_email_verified?(social_info)
|
|
87
|
+
stage_social_link!(identifier, subject, backfill_provider: false)
|
|
56
88
|
emit_social_account_created(account, provider, social_info)
|
|
57
89
|
account
|
|
58
90
|
end
|
|
@@ -68,15 +100,290 @@ module StandardId
|
|
|
68
100
|
|
|
69
101
|
return if strategy == :trust_provider
|
|
70
102
|
# nil provider means the identifier predates provider tracking — allow
|
|
71
|
-
# through since we can't retroactively determine its origin.
|
|
103
|
+
# through since we can't retroactively determine its origin. The
|
|
104
|
+
# email_verified check (validate_social_email_verified!) still applies.
|
|
72
105
|
return if identifier.provider.nil?
|
|
73
106
|
return if identifier.provider == provider.provider_name
|
|
74
107
|
return if account_has_social_identifier_from?(identifier.account, provider)
|
|
108
|
+
return if trusted_cross_provider_link?(identifier, provider)
|
|
109
|
+
|
|
110
|
+
refuse_social_link!(identifier, provider, :link_required)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The provider opted in via Providers::Base.trusted_for_linking? (only the
|
|
114
|
+
# org's own IdP, whose email claims the org verifies, should) AND the
|
|
115
|
+
# existing identifier's address is itself verified. This only lifts the
|
|
116
|
+
# :strict cross-provider refusal: validate_social_subject! and
|
|
117
|
+
# validate_social_email_verified! still run after it, so an unverified
|
|
118
|
+
# provider email or a different sub is refused exactly as before.
|
|
119
|
+
#
|
|
120
|
+
# The verified-identifier requirement closes pre-account hijacking: an
|
|
121
|
+
# account someone registered for an address they never proved must not
|
|
122
|
+
# be handed to the address's real owner arriving via the trusted IdP
|
|
123
|
+
# (the registrant would keep their own way in).
|
|
124
|
+
def trusted_cross_provider_link?(identifier, provider)
|
|
125
|
+
return false unless provider.respond_to?(:trusted_for_linking?)
|
|
126
|
+
return false unless provider.trusted_for_linking? == true
|
|
127
|
+
|
|
128
|
+
identifier.respond_to?(:verified?) && identifier.verified?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The identifier is already linked to another subject from this provider:
|
|
132
|
+
# a second provider account is claiming the same address.
|
|
133
|
+
def validate_social_subject!(identifier, provider, subject)
|
|
134
|
+
return if subject.nil?
|
|
135
|
+
return unless StandardId::SocialIdentity.available?
|
|
75
136
|
|
|
76
|
-
|
|
137
|
+
linked = StandardId::SocialIdentity.where(identifier_id: identifier.id, provider: provider.provider_name)
|
|
138
|
+
return unless linked.where.not(subject: subject).exists?
|
|
139
|
+
|
|
140
|
+
refuse_social_link!(identifier, provider, :subject_mismatch)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Linking a login to an EXISTING account by email needs the provider to
|
|
144
|
+
# vouch for the address, under either link_strategy.
|
|
145
|
+
def validate_social_email_verified!(identifier, provider, social_info)
|
|
146
|
+
return if social_email_verified?(social_info)
|
|
147
|
+
|
|
148
|
+
refuse_social_link!(identifier, provider, :email_unverified)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def refuse_social_link!(identifier, provider, reason)
|
|
152
|
+
emit_social_link_blocked(identifier, provider, reason)
|
|
77
153
|
raise StandardId::SocialLinkError.new(
|
|
78
154
|
email: identifier.value,
|
|
79
|
-
provider_name: provider.provider_name
|
|
155
|
+
provider_name: provider.provider_name,
|
|
156
|
+
reason: reason
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The provider's stable subject id (OIDC `sub`), or nil when the provider
|
|
161
|
+
# reports none.
|
|
162
|
+
def social_subject(social_info)
|
|
163
|
+
social_info[:sub].presence&.to_s
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Strict: only boolean true or the string "true" (any case) count. Apple
|
|
167
|
+
# sends `email_verified` as the string "true"; Google's tokeninfo endpoint
|
|
168
|
+
# does too. Google's OAuth2 v2 userinfo endpoint names the claim
|
|
169
|
+
# `verified_email` (standard_id-google <= 0.5.0 passes it through as-is),
|
|
170
|
+
# so it is accepted as a fallback.
|
|
171
|
+
def social_email_verified?(social_info)
|
|
172
|
+
value = social_info.key?(:email_verified) ? social_info[:email_verified] : social_info[:verified_email]
|
|
173
|
+
value.to_s.strip.casecmp?("true")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def find_social_identity(subject)
|
|
177
|
+
return nil if subject.nil?
|
|
178
|
+
return nil unless social_identities_available?
|
|
179
|
+
|
|
180
|
+
StandardId::SocialIdentity.includes(:account, :identifier).find_by(provider: provider.provider_name, subject: subject)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Writes the (provider, sub) row inside its own savepoint (it runs inside
|
|
184
|
+
# commit_social_link!'s transaction; on PostgreSQL a failed INSERT would
|
|
185
|
+
# otherwise abort it).
|
|
186
|
+
#
|
|
187
|
+
# Find-then-insert is spelled out rather than left to find_or_create_by!,
|
|
188
|
+
# because a concurrent login can commit a rival row at any point in it and
|
|
189
|
+
# each point surfaces differently: before the SELECT (the row is found),
|
|
190
|
+
# between the SELECT and validation (the uniqueness validations raise
|
|
191
|
+
# RecordInvalid), or between validation and the INSERT (the unique index
|
|
192
|
+
# raises RecordNotUnique; find_or_create_by! would turn an
|
|
193
|
+
# (identifier, provider) collision into RecordNotFound, and return a
|
|
194
|
+
# (provider, sub) winner for ANY account). Every one of them is classified
|
|
195
|
+
# by classify_social_link_race!.
|
|
196
|
+
def record_social_identity!(identifier, subject)
|
|
197
|
+
return if subject.nil?
|
|
198
|
+
return unless social_identities_available?
|
|
199
|
+
|
|
200
|
+
attributes = { provider: provider.provider_name, subject: subject }
|
|
201
|
+
existing = StandardId::SocialIdentity.find_by(attributes)
|
|
202
|
+
return classify_social_link_race!(identifier, subject) if existing
|
|
203
|
+
|
|
204
|
+
StandardId::SocialIdentity.transaction(requires_new: true) do
|
|
205
|
+
StandardId::SocialIdentity.create!(attributes.merge(account: identifier.account, identifier: identifier))
|
|
206
|
+
end
|
|
207
|
+
rescue ActiveRecord::RecordNotUnique
|
|
208
|
+
classify_social_link_race!(identifier, subject) || raise
|
|
209
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
210
|
+
raise unless social_link_race_invalid?(e.record)
|
|
211
|
+
|
|
212
|
+
classify_social_link_race!(identifier, subject) || raise
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# A RecordInvalid that is only the uniqueness validations losing a race.
|
|
216
|
+
# Anything else (a missing account, a host validation) is a real error.
|
|
217
|
+
def social_link_race_invalid?(record)
|
|
218
|
+
details = record&.errors&.details
|
|
219
|
+
return false if details.blank?
|
|
220
|
+
|
|
221
|
+
details.all? do |attribute, errors|
|
|
222
|
+
%i[subject identifier_id].include?(attribute) && errors.all? { |error| error[:error] == :taken }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Another login has linked first. Returns the winning row when it links
|
|
227
|
+
# this (provider, sub) to the same account (adopt it); raises
|
|
228
|
+
# SocialLinkConflictError when it links this sub to ANOTHER account
|
|
229
|
+
# (:subject_conflict) or this identifier to this provider under ANOTHER
|
|
230
|
+
# sub (:subject_mismatch); returns nil when no rival is found (it was
|
|
231
|
+
# removed again), so the caller re-raises the original error.
|
|
232
|
+
def classify_social_link_race!(identifier, subject)
|
|
233
|
+
winner = StandardId::SocialIdentity.find_by(provider: provider.provider_name, subject: subject)
|
|
234
|
+
if winner
|
|
235
|
+
return winner if winner.account_id == identifier.account_id
|
|
236
|
+
|
|
237
|
+
raise StandardId::SocialLinkConflictError.new(SOCIAL_RETRY_MESSAGE, identifier: identifier, reason: :subject_conflict)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
other_sub = StandardId::SocialIdentity.where(identifier_id: identifier.id, provider: provider.provider_name).where.not(subject: subject)
|
|
241
|
+
return nil unless other_sub.exists?
|
|
242
|
+
|
|
243
|
+
raise StandardId::SocialLinkConflictError.new(SOCIAL_RETRY_MESSAGE, identifier: identifier, reason: :subject_mismatch)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# The (provider, sub) link and the provider backfill are STAGED, not
|
|
247
|
+
# written, by find_or_create_account_from_social when the caller defers
|
|
248
|
+
# them (the web and API callbacks do), and written by commit_social_link!
|
|
249
|
+
# only once the login has been accepted. A rejected login therefore never
|
|
250
|
+
# writes a link, so rejecting it never has to delete one — and cannot
|
|
251
|
+
# delete a row a concurrent, successful callback for the same
|
|
252
|
+
# (provider, sub) has created or adopted in the meantime.
|
|
253
|
+
#
|
|
254
|
+
# Callers that do not defer (the default, e.g. host code calling
|
|
255
|
+
# find_or_create_account_from_social directly) get the link written
|
|
256
|
+
# immediately, as before.
|
|
257
|
+
def stage_social_link!(identifier, subject, backfill_provider:, emit_linked: false)
|
|
258
|
+
@pending_social_link = {
|
|
259
|
+
identifier: identifier,
|
|
260
|
+
subject: subject,
|
|
261
|
+
backfill_provider: backfill_provider,
|
|
262
|
+
emit_linked: emit_linked
|
|
263
|
+
}
|
|
264
|
+
commit_social_link! unless defer_social_link?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def defer_social_link?
|
|
268
|
+
false
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Writes the staged link in one transaction: the provider backfill and
|
|
272
|
+
# the (provider, sub) row land together or not at all, and the staged
|
|
273
|
+
# state is kept until they have, so a failure part-way leaves nothing to
|
|
274
|
+
# reverse. A block, when given, runs inside that transaction after the
|
|
275
|
+
# writes: work the login still has to get through for the link to stand
|
|
276
|
+
# (the web callback's redirect_to, which can raise). It runs even when
|
|
277
|
+
# nothing is staged. SOCIAL_ACCOUNT_LINKED is published only after the
|
|
278
|
+
# transaction has committed.
|
|
279
|
+
#
|
|
280
|
+
# The account row is locked first and must still exist. That serialises
|
|
281
|
+
# this commit with AccountCleanup.destroy_newly_created!, which takes the
|
|
282
|
+
# same lock: an account another, rejected request created and is now
|
|
283
|
+
# removing is never linked to (this login fails and can simply be
|
|
284
|
+
# retried, creating a fresh account), and an account this login has
|
|
285
|
+
# already signed in to is never removed under it (see AccountCleanup).
|
|
286
|
+
def commit_social_link!
|
|
287
|
+
pending = @pending_social_link
|
|
288
|
+
if pending.nil?
|
|
289
|
+
yield if block_given?
|
|
290
|
+
return
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
identifier = pending[:identifier]
|
|
294
|
+
begin
|
|
295
|
+
write_social_link!(pending, identifier) { yield if block_given? }
|
|
296
|
+
rescue StandardId::SocialLinkConflictError => e
|
|
297
|
+
# A concurrent login won the link (see the error's reasons).
|
|
298
|
+
# Published here, once the transaction has rolled back, so a
|
|
299
|
+
# subscriber that writes an audit row is not rolled back with the
|
|
300
|
+
# link; and only on this path, so never for a link that commits.
|
|
301
|
+
emit_social_link_blocked(identifier, provider, e.reason)
|
|
302
|
+
raise
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
@pending_social_link = nil
|
|
306
|
+
if pending[:backfill_provider]
|
|
307
|
+
identifier.provider = provider.provider_name
|
|
308
|
+
identifier.clear_attribute_changes([:provider]) if identifier.respond_to?(:clear_attribute_changes)
|
|
309
|
+
end
|
|
310
|
+
emit_committed_social_link(identifier, provider) if pending[:emit_linked]
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The transaction behind commit_social_link!.
|
|
314
|
+
def write_social_link!(pending, identifier)
|
|
315
|
+
ActiveRecord::Base.transaction do
|
|
316
|
+
if StandardId.account_class.lock.where(id: identifier.account_id).pick(:id).nil?
|
|
317
|
+
raise StandardId::InvalidGrantError, SOCIAL_RETRY_MESSAGE
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
if pending[:backfill_provider]
|
|
321
|
+
# Conditional, so a provider set concurrently is never overwritten.
|
|
322
|
+
StandardId::Identifier.where(id: identifier.id, provider: nil).update_all(provider: provider.provider_name)
|
|
323
|
+
end
|
|
324
|
+
record_social_identity!(identifier, pending[:subject])
|
|
325
|
+
yield if block_given?
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# SOCIAL_ACCOUNT_LINKED reports a link that has already committed, so it
|
|
330
|
+
# cannot refuse the login: a subscriber that raises here would fail the
|
|
331
|
+
# callback while the link stays. Its error is logged and reported to
|
|
332
|
+
# Rails.error instead of propagating. (Guards belong on the events that
|
|
333
|
+
# run before anything is written.)
|
|
334
|
+
def emit_committed_social_link(identifier, provider)
|
|
335
|
+
emit_social_account_linked(identifier.account, provider, identifier)
|
|
336
|
+
rescue StandardError => e
|
|
337
|
+
Rails.logger&.error("[StandardId] SOCIAL_ACCOUNT_LINKED subscriber raised after the link committed: #{e.class}: #{e.message}")
|
|
338
|
+
Rails.error.report(e, handled: true, source: "standard_id")
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# A social login that recorded a link (and maybe created an account) and
|
|
342
|
+
# was then rejected for ANY reason — policy, hook, invalid scope, audience
|
|
343
|
+
# binding, an unexpected error — must leave nothing behind: drop the link
|
|
344
|
+
# and remove an account this request created. Safe to call when nothing
|
|
345
|
+
# was recorded, and when the account is already gone.
|
|
346
|
+
def discard_social_attempt!(account, newly_created:)
|
|
347
|
+
rollback_social_link!
|
|
348
|
+
StandardId::AccountCleanup.destroy_newly_created!(account) if newly_created
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The login matched an account by email that a concurrent, refused
|
|
352
|
+
# request had created and has since removed (AccountCleanup). The login
|
|
353
|
+
# then fails part-way (its session insert hits the foreign key, or
|
|
354
|
+
# commit_social_link! finds the account gone); the callbacks report that
|
|
355
|
+
# as a retryable invalid_grant rather than a 500. A retry finds no
|
|
356
|
+
# account and creates a fresh one. Check before discard_social_attempt!,
|
|
357
|
+
# which removes an account THIS request created.
|
|
358
|
+
def social_account_removed_concurrently?(account, newly_created:)
|
|
359
|
+
return false if newly_created || account.nil? || account.id.nil?
|
|
360
|
+
|
|
361
|
+
!StandardId.account_class.where(id: account.id).exists?
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
SOCIAL_RETRY_MESSAGE = "The sign-in could not be completed. Please try again.".freeze
|
|
365
|
+
|
|
366
|
+
# Nothing was written for a deferred link, so dropping the staged one is
|
|
367
|
+
# the whole rollback. (A non-deferring caller has already committed it.)
|
|
368
|
+
def rollback_social_link!
|
|
369
|
+
@pending_social_link = nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def social_identities_available?
|
|
373
|
+
return true if StandardId::SocialIdentity.available?
|
|
374
|
+
|
|
375
|
+
StandardId::SocialAuthentication.warn_social_identities_missing!
|
|
376
|
+
false
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Logged once per process when the host has not run the migration yet.
|
|
380
|
+
def self.warn_social_identities_missing!
|
|
381
|
+
return if @social_identities_missing_warned
|
|
382
|
+
|
|
383
|
+
@social_identities_missing_warned = true
|
|
384
|
+
Rails.logger&.warn(
|
|
385
|
+
"[StandardId] standard_id_social_identities is missing, so social logins are not matched on the " \
|
|
386
|
+
"provider's subject id. Run `bin/rails standard_id:install:migrations && bin/rails db:migrate`."
|
|
80
387
|
)
|
|
81
388
|
end
|
|
82
389
|
|
|
@@ -154,13 +461,14 @@ module StandardId
|
|
|
154
461
|
)
|
|
155
462
|
end
|
|
156
463
|
|
|
157
|
-
def emit_social_link_blocked(identifier, provider)
|
|
464
|
+
def emit_social_link_blocked(identifier, provider, reason = :link_required)
|
|
158
465
|
StandardId::Events.publish(
|
|
159
466
|
StandardId::Events::SOCIAL_LINK_BLOCKED,
|
|
160
467
|
email: identifier.value,
|
|
161
468
|
provider: provider,
|
|
162
469
|
identifier: identifier,
|
|
163
|
-
account: identifier.account
|
|
470
|
+
account: identifier.account,
|
|
471
|
+
reason: reason
|
|
164
472
|
)
|
|
165
473
|
end
|
|
166
474
|
|
|
@@ -154,7 +154,12 @@ module StandardId
|
|
|
154
154
|
# but before session creation. The block may raise AuthenticationDenied.
|
|
155
155
|
before_session&.call(password_credential.account)
|
|
156
156
|
|
|
157
|
-
session_manager.sign_in_account(
|
|
157
|
+
session_manager.sign_in_account(
|
|
158
|
+
password_credential.account,
|
|
159
|
+
scope_name: request.path_parameters[:scope],
|
|
160
|
+
auth_method: :password,
|
|
161
|
+
flow: :web_password
|
|
162
|
+
)
|
|
158
163
|
session_manager.set_remember_cookie(password_credential) if remember_me
|
|
159
164
|
|
|
160
165
|
StandardId::Events.publish(
|
|
@@ -19,7 +19,11 @@ module StandardId
|
|
|
19
19
|
reject_invalid_redirect_uri!
|
|
20
20
|
return redirect_to_consent if consent_required?
|
|
21
21
|
|
|
22
|
-
response_data = flow_strategy_class.new(
|
|
22
|
+
response_data = flow_strategy_class.new(
|
|
23
|
+
flow_strategy_params, request,
|
|
24
|
+
current_account: current_account,
|
|
25
|
+
auth_lineage: StandardId::AuthLineage.from_session(authorization_session_manager.current_session)
|
|
26
|
+
).execute
|
|
23
27
|
|
|
24
28
|
if response_data[:redirect_to]
|
|
25
29
|
redirect_to response_data[:redirect_to], status: response_data[:status] || :found, allow_other_host: true
|
|
@@ -150,10 +154,13 @@ module StandardId
|
|
|
150
154
|
end
|
|
151
155
|
|
|
152
156
|
def current_account
|
|
153
|
-
@current_account ||=
|
|
157
|
+
@current_account ||= authorization_session_manager.current_account
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def authorization_session_manager
|
|
161
|
+
@authorization_session_manager ||= begin
|
|
154
162
|
token_manager = StandardId::Web::TokenManager.new(request)
|
|
155
|
-
|
|
156
|
-
session_manager.current_account
|
|
163
|
+
StandardId::Web::SessionManager.new(token_manager, request: request, session: session, cookies: cookies)
|
|
157
164
|
end
|
|
158
165
|
end
|
|
159
166
|
end
|
|
@@ -26,6 +26,11 @@ module StandardId
|
|
|
26
26
|
rescue_from StandardId::AccountDeactivatedError, with: :handle_account_deactivated
|
|
27
27
|
rescue_from StandardId::AccountLockedError, with: :handle_account_locked
|
|
28
28
|
|
|
29
|
+
# config.login_method_policy refused the method (token grants, social
|
|
30
|
+
# callback). Not an OAuthError — it is an AuthenticationDenied so the
|
|
31
|
+
# WebEngine's existing handling covers it — so it is mapped here.
|
|
32
|
+
rescue_from StandardId::LoginMethodDenied, with: :handle_login_method_denied
|
|
33
|
+
|
|
29
34
|
protected
|
|
30
35
|
|
|
31
36
|
def validate_content_type!
|
|
@@ -74,6 +79,17 @@ module StandardId
|
|
|
74
79
|
render_bearer_unauthorized!(error_description: "The account is locked")
|
|
75
80
|
end
|
|
76
81
|
|
|
82
|
+
# `403 access_denied`, the shape SocialLinkError already uses on the API
|
|
83
|
+
# callback. The policy only runs after the credential was proven, so the
|
|
84
|
+
# caller already controls it and the policy's message (e.g. "Staff must
|
|
85
|
+
# sign in with ...") tells them nothing they could not learn otherwise.
|
|
86
|
+
def handle_login_method_denied(error)
|
|
87
|
+
render json: {
|
|
88
|
+
error: error.oauth_error_code.to_s,
|
|
89
|
+
error_description: error.message
|
|
90
|
+
}, status: error.http_status
|
|
91
|
+
end
|
|
92
|
+
|
|
77
93
|
def handle_oauth_error(error)
|
|
78
94
|
render json: {
|
|
79
95
|
error: error.oauth_error_code,
|
|
@@ -23,28 +23,74 @@ module StandardId
|
|
|
23
23
|
social_info = provider_response[:user_info]
|
|
24
24
|
provider_tokens = provider_response[:tokens]
|
|
25
25
|
account = find_or_create_account_from_social(social_info)
|
|
26
|
+
newly_created = account.previously_new_record?
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
# Everything after find_or_create_account_from_social can still
|
|
29
|
+
# reject the login: SocialFlow.new (InvalidScopeError), the grant's
|
|
30
|
+
# audience/profile binding (InvalidGrantError), the login-method
|
|
31
|
+
# policy, a SOCIAL_AUTH_COMPLETED subscriber, or anything unexpected.
|
|
32
|
+
# Any of them leaves no link (it is only written on acceptance), no
|
|
33
|
+
# new account, and no usable token or session from this request.
|
|
34
|
+
token_response = nil
|
|
35
|
+
flow = nil
|
|
36
|
+
begin
|
|
37
|
+
flow = StandardId::Oauth::SocialFlow.new(
|
|
38
|
+
params,
|
|
39
|
+
request,
|
|
40
|
+
account:,
|
|
41
|
+
connection: provider.provider_name,
|
|
42
|
+
scopes: params[:scope]
|
|
43
|
+
)
|
|
44
|
+
token_response = flow.execute
|
|
45
|
+
run_social_callback(
|
|
46
|
+
provider: provider.provider_name,
|
|
47
|
+
social_info:,
|
|
48
|
+
provider_tokens:,
|
|
49
|
+
account:,
|
|
50
|
+
original_request_params: forwarded_request_params
|
|
51
|
+
)
|
|
52
|
+
commit_social_link!
|
|
53
|
+
rescue StandardError
|
|
54
|
+
removed = social_account_removed_concurrently?(account, newly_created:)
|
|
55
|
+
revoke_issued_tokens!(flow)
|
|
56
|
+
discard_social_attempt!(account, newly_created: newly_created)
|
|
57
|
+
raise unless removed
|
|
58
|
+
|
|
59
|
+
# The matched account was removed under this login by a
|
|
60
|
+
# concurrent, refused request: retryable, not a 500.
|
|
61
|
+
raise StandardId::InvalidGrantError, SOCIAL_RETRY_MESSAGE
|
|
62
|
+
end
|
|
34
63
|
|
|
35
|
-
token_response = flow.execute
|
|
36
|
-
run_social_callback(
|
|
37
|
-
provider: provider.provider_name,
|
|
38
|
-
social_info:,
|
|
39
|
-
provider_tokens:,
|
|
40
|
-
account:,
|
|
41
|
-
original_request_params: forwarded_request_params
|
|
42
|
-
)
|
|
43
64
|
render json: token_response, status: :ok
|
|
44
65
|
end
|
|
45
66
|
|
|
46
67
|
private
|
|
47
68
|
|
|
69
|
+
# Write the (provider, sub) link only once the login is accepted.
|
|
70
|
+
def defer_social_link?
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The response was never sent, but the grant may already have
|
|
75
|
+
# persisted a refresh token and a session — also when the grant itself
|
|
76
|
+
# raised after writing them (e.g. an OAUTH_TOKEN_ISSUED subscriber), so
|
|
77
|
+
# they are read from the flow, not from a token response. Revoke them
|
|
78
|
+
# so nothing from the rejected request stays usable, and so that
|
|
79
|
+
# AccountCleanup does not mistake them for a concurrent login using a
|
|
80
|
+
# new account. A token whose transaction rolled back is not persisted
|
|
81
|
+
# and is skipped (its session write was rolled back with it).
|
|
82
|
+
def revoke_issued_tokens!(flow)
|
|
83
|
+
record = flow&.issued_refresh_token
|
|
84
|
+
return unless record&.persisted?
|
|
85
|
+
|
|
86
|
+
# Loaded by id, not through the association: the record was built
|
|
87
|
+
# with only session_id, and a host with strict_loading_by_default
|
|
88
|
+
# would raise here and mask the original error.
|
|
89
|
+
session = record.session_id && StandardId::Session.find_by(id: record.session_id)
|
|
90
|
+
session.revoke!(reason: "social_sign_in_rejected") unless session.nil? || session.revoked?
|
|
91
|
+
record.revoke!
|
|
92
|
+
end
|
|
93
|
+
|
|
48
94
|
# Mirror of the web callback's OAuthError handling: emit
|
|
49
95
|
# SOCIAL_AUTH_FAILED for infrastructure-level provider failures
|
|
50
96
|
# (HTTP/DNS/SSL/timeouts surfaced as OAuthError by provider
|
|
@@ -40,7 +40,13 @@ module StandardId
|
|
|
40
40
|
newly_created = account.previously_new_record?
|
|
41
41
|
|
|
42
42
|
invoke_before_sign_in(account, { mechanism: "social", provider: provider.provider_name })
|
|
43
|
-
session_manager.sign_in_account(
|
|
43
|
+
session_manager.sign_in_account(
|
|
44
|
+
account,
|
|
45
|
+
scope_name: state_data&.dig("scope"),
|
|
46
|
+
auth_method: :social,
|
|
47
|
+
provider: provider.provider_name,
|
|
48
|
+
flow: :web_social
|
|
49
|
+
)
|
|
44
50
|
|
|
45
51
|
provider_name = provider.provider_name
|
|
46
52
|
invoke_after_account_created(account, { mechanism: "social", provider: provider_name }) if newly_created
|
|
@@ -68,8 +74,14 @@ module StandardId
|
|
|
68
74
|
destination = redirect_override || (safe_destination?(caller_redirect_uri) ? caller_redirect_uri : safe_post_signin_default)
|
|
69
75
|
redirect_options = { notice: "Successfully signed in with #{provider_name.humanize}" }
|
|
70
76
|
redirect_options[:allow_other_host] = true if allow_other_host_redirect?(destination)
|
|
71
|
-
|
|
77
|
+
|
|
78
|
+
# Accepted: only now write the (provider, sub) link, in the same
|
|
79
|
+
# transaction as the redirect, which can still raise (e.g. an
|
|
80
|
+
# after_sign_in URL on a host that is not allowed). If it does,
|
|
81
|
+
# the link is rolled back with it.
|
|
82
|
+
commit_social_link! { redirect_to destination, redirect_options }
|
|
72
83
|
rescue StandardId::AuthenticationDenied => e
|
|
84
|
+
rollback_social_link!
|
|
73
85
|
handle_authentication_denied(e, account: account, newly_created: newly_created)
|
|
74
86
|
rescue StandardId::SocialLinkError => e
|
|
75
87
|
# Policy/link error — SOCIAL_LINK_BLOCKED has already been emitted
|
|
@@ -77,8 +89,23 @@ module StandardId
|
|
|
77
89
|
# (which is reserved for infrastructure-level failures).
|
|
78
90
|
redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
|
|
79
91
|
rescue StandardId::OAuthError => e
|
|
80
|
-
|
|
92
|
+
discard_rejected_social_sign_in!(account, newly_created:)
|
|
93
|
+
# A (provider, sub) conflict with a concurrent login is a link
|
|
94
|
+
# refusal (SOCIAL_LINK_BLOCKED, already published), not an
|
|
95
|
+
# infrastructure failure.
|
|
96
|
+
emit_social_auth_failed(e, account: account) unless e.is_a?(StandardId::SocialLinkConflictError)
|
|
81
97
|
redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
# Unexpected failure after the link/account may have been
|
|
100
|
+
# written: undo them, then let the error surface as before —
|
|
101
|
+
# unless the matched account was removed under this login by a
|
|
102
|
+
# concurrent, refused request, which is retryable.
|
|
103
|
+
removed = social_account_removed_concurrently?(account, newly_created:)
|
|
104
|
+
discard_rejected_social_sign_in!(account, newly_created:)
|
|
105
|
+
raise unless removed
|
|
106
|
+
|
|
107
|
+
emit_social_auth_failed(e, account: account)
|
|
108
|
+
redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{SOCIAL_RETRY_MESSAGE}"
|
|
82
109
|
end
|
|
83
110
|
end
|
|
84
111
|
|
|
@@ -103,6 +130,27 @@ module StandardId
|
|
|
103
130
|
|
|
104
131
|
private
|
|
105
132
|
|
|
133
|
+
# A rejection after find_or_create_account_from_social leaves no
|
|
134
|
+
# link, no new account and no session — including one that
|
|
135
|
+
# sign_in_account created before raising (a failing SESSION_CREATED
|
|
136
|
+
# subscriber), which is why this reads session_manager.created_session
|
|
137
|
+
# rather than relying on sign_in_account having returned.
|
|
138
|
+
# AuthenticationDenied has its own path (handle_authentication_denied);
|
|
139
|
+
# SocialLinkError is raised before anything is written.
|
|
140
|
+
def discard_rejected_social_sign_in!(account, newly_created:)
|
|
141
|
+
created = session_manager.created_session
|
|
142
|
+
if created
|
|
143
|
+
created.revoke!(reason: "social_sign_in_rejected") unless created.revoked?
|
|
144
|
+
session_manager.clear_session!
|
|
145
|
+
end
|
|
146
|
+
discard_social_attempt!(account, newly_created: newly_created)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Write the (provider, sub) link only once the login is accepted.
|
|
150
|
+
def defer_social_link?
|
|
151
|
+
true
|
|
152
|
+
end
|
|
153
|
+
|
|
106
154
|
def callback_url_for
|
|
107
155
|
"#{request.base_url}#{provider.callback_path}"
|
|
108
156
|
end
|
|
@@ -81,7 +81,11 @@ module StandardId
|
|
|
81
81
|
)
|
|
82
82
|
|
|
83
83
|
result = StandardId::Oauth::AuthorizationCodeAuthorizationFlow
|
|
84
|
-
.new(
|
|
84
|
+
.new(
|
|
85
|
+
@consent_request, request,
|
|
86
|
+
current_account: current_account,
|
|
87
|
+
auth_lineage: StandardId::AuthLineage.from_session(current_session)
|
|
88
|
+
)
|
|
85
89
|
.execute
|
|
86
90
|
|
|
87
91
|
redirect_out(result[:redirect_to], status: result[:status] || :found)
|
|
@@ -52,7 +52,12 @@ module StandardId
|
|
|
52
52
|
|
|
53
53
|
invoke_before_sign_in(account, { mechanism: "passwordless", provider: nil })
|
|
54
54
|
|
|
55
|
-
session_manager.sign_in_account(
|
|
55
|
+
session_manager.sign_in_account(
|
|
56
|
+
account,
|
|
57
|
+
scope_name: request.path_parameters[:scope],
|
|
58
|
+
auth_method: :passwordless,
|
|
59
|
+
flow: :web_passwordless
|
|
60
|
+
)
|
|
56
61
|
emit_authentication_succeeded(account)
|
|
57
62
|
|
|
58
63
|
if newly_created
|
|
@@ -47,7 +47,12 @@ module StandardId
|
|
|
47
47
|
|
|
48
48
|
if form.submit
|
|
49
49
|
invoke_before_sign_in(form.account, { mechanism: "password", provider: nil })
|
|
50
|
-
session_manager.sign_in_account(
|
|
50
|
+
session_manager.sign_in_account(
|
|
51
|
+
form.account,
|
|
52
|
+
scope_name: request.path_parameters[:scope],
|
|
53
|
+
auth_method: :password,
|
|
54
|
+
flow: :web_signup
|
|
55
|
+
)
|
|
51
56
|
invoke_after_account_created(form.account, { mechanism: "signup", provider: nil })
|
|
52
57
|
|
|
53
58
|
redirect_uri = string_param(:redirect_uri)
|