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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +148 -3
  4. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +3 -18
  5. data/app/controllers/concerns/standard_id/social_authentication.rb +316 -8
  6. data/app/controllers/concerns/standard_id/web_authentication.rb +6 -1
  7. data/app/controllers/standard_id/api/authorization_controller.rb +11 -4
  8. data/app/controllers/standard_id/api/base_controller.rb +16 -0
  9. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +61 -15
  10. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +51 -3
  11. data/app/controllers/standard_id/web/consent_controller.rb +5 -1
  12. data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
  13. data/app/controllers/standard_id/web/signup_controller.rb +6 -1
  14. data/app/models/standard_id/social_identity.rb +30 -0
  15. data/db/migrate/20261002000000_create_standard_id_social_identities.rb +44 -0
  16. data/db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb +17 -0
  17. data/lib/generators/standard_id/install/templates/standard_id.rb +21 -4
  18. data/lib/standard_id/account_cleanup.rb +62 -0
  19. data/lib/standard_id/api/token_manager.rb +18 -2
  20. data/lib/standard_id/auth_lineage.rb +75 -0
  21. data/lib/standard_id/config/callable_validator.rb +5 -0
  22. data/lib/standard_id/config/schema.rb +18 -0
  23. data/lib/standard_id/errors.rb +68 -2
  24. data/lib/standard_id/events/definitions.rb +6 -1
  25. data/lib/standard_id/events/subscribers/logging_subscriber.rb +1 -0
  26. data/lib/standard_id/login_method_policy.rb +108 -0
  27. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +2 -1
  28. data/lib/standard_id/oauth/authorization_code_flow.rb +6 -0
  29. data/lib/standard_id/oauth/authorization_flow.rb +6 -2
  30. data/lib/standard_id/oauth/password_flow.rb +13 -2
  31. data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
  32. data/lib/standard_id/oauth/refresh_token_flow.rb +32 -0
  33. data/lib/standard_id/oauth/social_flow.rb +4 -0
  34. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
  35. data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
  36. data/lib/standard_id/providers/base.rb +34 -0
  37. data/lib/standard_id/version.rb +1 -1
  38. data/lib/standard_id/web/session_manager.rb +50 -3
  39. data/lib/standard_id/web/token_manager.rb +8 -3
  40. data/lib/standard_id.rb +3 -0
  41. 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
- identifier.update!(provider: provider.provider_name) if identifier.provider.nil?
46
- emit_social_account_linked(identifier.account, provider, identifier)
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!) && [true, "true"].include?(social_info[:email_verified])
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
- emit_social_link_blocked(identifier, provider)
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(password_credential.account, scope_name: request.path_parameters[:scope])
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(flow_strategy_params, request, current_account: current_account).execute
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 ||= begin
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
- session_manager = StandardId::Web::SessionManager.new(token_manager, request: request, session: session, cookies: cookies)
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
- flow = StandardId::Oauth::SocialFlow.new(
28
- params,
29
- request,
30
- account:,
31
- connection: provider.provider_name,
32
- scopes: params[:scope]
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(account, scope_name: state_data&.dig("scope"))
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
- redirect_to destination, redirect_options
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
- emit_social_auth_failed(e, account: account)
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(@consent_request, request, current_account: current_account)
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(account, scope_name: request.path_parameters[:scope])
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(form.account, scope_name: request.path_parameters[:scope])
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)