standard_id 0.44.0 → 0.46.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +167 -3
  4. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +12 -19
  5. data/app/controllers/concerns/standard_id/social_authentication.rb +267 -13
  6. data/app/controllers/concerns/standard_id/web/social_login_params.rb +6 -2
  7. data/app/controllers/concerns/standard_id/web_authentication.rb +6 -1
  8. data/app/controllers/standard_id/api/authorization_controller.rb +11 -4
  9. data/app/controllers/standard_id/api/base_controller.rb +16 -0
  10. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +72 -16
  11. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +69 -6
  12. data/app/controllers/standard_id/web/consent_controller.rb +5 -1
  13. data/app/controllers/standard_id/web/login_controller.rb +8 -0
  14. data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
  15. data/app/controllers/standard_id/web/signup_controller.rb +6 -1
  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 +13 -0
  18. data/lib/standard_id/account_cleanup.rb +147 -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 +14 -0
  23. data/lib/standard_id/errors.rb +55 -0
  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/social_login_grant.rb +6 -0
  35. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
  36. data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
  37. data/lib/standard_id/providers/base.rb +87 -2
  38. data/lib/standard_id/version.rb +1 -1
  39. data/lib/standard_id/web/session_manager.rb +50 -3
  40. data/lib/standard_id/web/token_manager.rb +8 -3
  41. data/lib/standard_id.rb +3 -0
  42. metadata +5 -1
@@ -18,19 +18,32 @@ module StandardId
18
18
  raise StandardId::InvalidRequestError, e.message
19
19
  end
20
20
 
21
- def get_user_info_from_provider(redirect_uri: nil, nonce: nil, flow: :web)
21
+ # `callback_iss` is the callback's RFC 9207 `iss` parameter (a String
22
+ # only; anything else is dropped), for providers that defend against
23
+ # authorization-server mix-up. `code_verifier` is passed only by the web
24
+ # callback, from the server-held flow state, for providers that opt in to
25
+ # core-managed PKCE (Providers::Base.supports_pkce?). Nil values are not
26
+ # passed, so a provider that ignores both sees no change.
27
+ def get_user_info_from_provider(redirect_uri: nil, nonce: nil, code_verifier: nil, flow: :web)
22
28
  provider_params = {
23
29
  code: params[:code],
24
30
  id_token: params[:id_token],
25
31
  access_token: params[:access_token],
26
32
  redirect_uri:,
27
- nonce:
33
+ nonce:,
34
+ callback_iss: callback_iss_param,
35
+ code_verifier:
28
36
  }
29
37
 
30
38
  resolved_params = provider.resolve_params(provider_params, context: { flow: flow })
31
39
  provider.get_user_info(**resolved_params.compact)
32
40
  end
33
41
 
42
+ def callback_iss_param
43
+ iss = params[:iss]
44
+ iss.is_a?(String) && iss.present? ? iss : nil
45
+ end
46
+
34
47
  # Resolves the account for a social login, in this order:
35
48
  #
36
49
  # 1. (provider, sub) matches a StandardId::SocialIdentity → that account.
@@ -38,7 +51,9 @@ module StandardId
38
51
  # reports is not consulted.
39
52
  # 2. The email matches an existing EmailIdentifier → link to that account,
40
53
  # but only when
41
- # - the link_strategy allows it (validate_social_link!),
54
+ # - the link_strategy allows it (validate_social_link!; under :strict a
55
+ # provider that is trusted_for_linking? may link across providers
56
+ # to a verified identifier),
42
57
  # - the identifier is not already linked to a DIFFERENT sub from this
43
58
  # provider (possible takeover), and
44
59
  # - the provider reports the email as verified. Without that, the
@@ -70,9 +85,9 @@ module StandardId
70
85
  validate_social_link!(identifier, provider)
71
86
  validate_social_subject!(identifier, provider, subject)
72
87
  validate_social_email_verified!(identifier, provider, social_info)
73
- identifier.update!(provider: provider.provider_name) if identifier.provider.nil?
74
- record_social_identity!(identifier, subject)
75
- emit_social_account_linked(identifier.account, provider, identifier)
88
+ # SOCIAL_ACCOUNT_LINKED is published by commit_social_link!, once the
89
+ # link has been written — never for a staged link that is dropped.
90
+ stage_social_link!(identifier, subject, backfill_provider: identifier.provider.nil?, emit_linked: true)
76
91
  identifier.account
77
92
  else
78
93
  account = build_account_from_social(social_info)
@@ -82,7 +97,7 @@ module StandardId
82
97
  provider: provider.provider_name
83
98
  )
84
99
  identifier.verify! if identifier.respond_to?(:verify!) && social_email_verified?(social_info)
85
- record_social_identity!(identifier, subject)
100
+ stage_social_link!(identifier, subject, backfill_provider: false)
86
101
  emit_social_account_created(account, provider, social_info)
87
102
  account
88
103
  end
@@ -103,10 +118,29 @@ module StandardId
103
118
  return if identifier.provider.nil?
104
119
  return if identifier.provider == provider.provider_name
105
120
  return if account_has_social_identifier_from?(identifier.account, provider)
121
+ return if trusted_cross_provider_link?(identifier, provider)
106
122
 
107
123
  refuse_social_link!(identifier, provider, :link_required)
108
124
  end
109
125
 
126
+ # The provider opted in via Providers::Base.trusted_for_linking? (only the
127
+ # org's own IdP, whose email claims the org verifies, should) AND the
128
+ # existing identifier's address is itself verified. This only lifts the
129
+ # :strict cross-provider refusal: validate_social_subject! and
130
+ # validate_social_email_verified! still run after it, so an unverified
131
+ # provider email or a different sub is refused exactly as before.
132
+ #
133
+ # The verified-identifier requirement closes pre-account hijacking: an
134
+ # account someone registered for an address they never proved must not
135
+ # be handed to the address's real owner arriving via the trusted IdP
136
+ # (the registrant would keep their own way in).
137
+ def trusted_cross_provider_link?(identifier, provider)
138
+ return false unless provider.respond_to?(:trusted_for_linking?)
139
+ return false unless provider.trusted_for_linking? == true
140
+
141
+ identifier.respond_to?(:verified?) && identifier.verified?
142
+ end
143
+
110
144
  # The identifier is already linked to another subject from this provider:
111
145
  # a second provider account is claiming the same address.
112
146
  def validate_social_subject!(identifier, provider, subject)
@@ -159,19 +193,239 @@ module StandardId
159
193
  StandardId::SocialIdentity.includes(:account, :identifier).find_by(provider: provider.provider_name, subject: subject)
160
194
  end
161
195
 
196
+ # Writes the (provider, sub) row inside its own savepoint (it runs inside
197
+ # commit_social_link!'s transaction; on PostgreSQL a failed INSERT would
198
+ # otherwise abort it).
199
+ #
200
+ # Find-then-insert is spelled out rather than left to find_or_create_by!,
201
+ # because a concurrent login can commit a rival row at any point in it and
202
+ # each point surfaces differently: before the SELECT (the row is found),
203
+ # between the SELECT and validation (the uniqueness validations raise
204
+ # RecordInvalid), or between validation and the INSERT (the unique index
205
+ # raises RecordNotUnique; find_or_create_by! would turn an
206
+ # (identifier, provider) collision into RecordNotFound, and return a
207
+ # (provider, sub) winner for ANY account). Every one of them is classified
208
+ # by classify_social_link_race!.
209
+ #
210
+ # A row found by the first lookup can be removed again (e.g. by a
211
+ # concurrent account cleanup) before classify_social_link_race! re-reads
212
+ # it. The (provider, sub) is then free, so the insert goes ahead: returning
213
+ # instead would let commit_social_link! backfill the provider and publish
214
+ # SOCIAL_ACCOUNT_LINKED for a link no row records.
162
215
  def record_social_identity!(identifier, subject)
163
216
  return if subject.nil?
164
217
  return unless social_identities_available?
165
218
 
166
- StandardId::SocialIdentity.find_or_create_by!(
167
- provider: provider.provider_name,
168
- subject: subject
169
- ) do |social_identity|
170
- social_identity.account = identifier.account
171
- social_identity.identifier = identifier
219
+ attributes = { provider: provider.provider_name, subject: subject }
220
+ if StandardId::SocialIdentity.find_by(attributes)
221
+ winner = classify_social_link_race!(identifier, subject)
222
+ return winner if winner
223
+ end
224
+
225
+ StandardId::SocialIdentity.transaction(requires_new: true) do
226
+ StandardId::SocialIdentity.create!(attributes.merge(account: identifier.account, identifier: identifier))
227
+ end
228
+ rescue ActiveRecord::RecordNotUnique
229
+ classify_social_link_race!(identifier, subject) || raise
230
+ rescue ActiveRecord::RecordInvalid => e
231
+ raise unless social_link_race_invalid?(e.record)
232
+
233
+ classify_social_link_race!(identifier, subject) || insert_past_stale_uniqueness!(e.record, identifier, subject)
234
+ end
235
+
236
+ # The uniqueness validations said `taken`, but the locking re-read in
237
+ # classify_social_link_race! finds no rival: the validations read a stale
238
+ # snapshot. Under MySQL/InnoDB REPEATABLE READ they read the transaction's
239
+ # snapshot, which still holds a row a concurrent cleanup has since removed
240
+ # (the vanished-row case above), and every later validation would say
241
+ # `taken` again. Every other validation passed (social_link_race_invalid?),
242
+ # and both uniqueness rules are backed by unique indexes, so insert once
243
+ # without validating and let the indexes decide; a rival that commits in
244
+ # the meantime surfaces as RecordNotUnique and is classified as usual.
245
+ def insert_past_stale_uniqueness!(record, identifier, subject)
246
+ StandardId::SocialIdentity.transaction(requires_new: true) { record.save!(validate: false) }
247
+ record
248
+ rescue ActiveRecord::RecordNotUnique
249
+ classify_social_link_race!(identifier, subject) || raise
250
+ end
251
+
252
+ # A RecordInvalid that is only the uniqueness validations losing a race.
253
+ # Anything else (a missing account, a host validation) is a real error.
254
+ def social_link_race_invalid?(record)
255
+ details = record&.errors&.details
256
+ return false if details.blank?
257
+
258
+ details.all? do |attribute, errors|
259
+ %i[subject identifier_id].include?(attribute) && errors.all? { |error| error[:error] == :taken }
260
+ end
261
+ end
262
+
263
+ # Another login has linked first. Returns the winning row when it links
264
+ # this (provider, sub) to the same account (adopt it); raises
265
+ # SocialLinkConflictError when it links this sub to ANOTHER account
266
+ # (:subject_conflict) or this identifier to this provider under ANOTHER
267
+ # sub (:subject_mismatch); returns nil when no rival is found (it was
268
+ # removed again), so the caller re-raises the original error.
269
+ #
270
+ # Both lookups are locking reads (SELECT ... FOR UPDATE). They run inside
271
+ # commit_social_link!'s transaction, and under MySQL/InnoDB's default
272
+ # REPEATABLE READ a plain SELECT there reads the snapshot taken by the
273
+ # transaction's first plain read (record_social_identity!'s own lookup),
274
+ # which predates the rival that just made the INSERT fail: the rival
275
+ # would be invisible, the race unclassified, and the login a 500 with no
276
+ # SOCIAL_LINK_BLOCKED. A locking read always reads the latest committed
277
+ # row. Under PostgreSQL's READ COMMITTED (and on SQLite, where `lock` is
278
+ # a no-op) a plain read already saw it; the lock only holds the rival row
279
+ # until this transaction ends.
280
+ def classify_social_link_race!(identifier, subject)
281
+ winner = StandardId::SocialIdentity.lock.find_by(provider: provider.provider_name, subject: subject)
282
+ if winner
283
+ return winner if winner.account_id == identifier.account_id
284
+
285
+ raise StandardId::SocialLinkConflictError.new(SOCIAL_RETRY_MESSAGE, identifier: identifier, reason: :subject_conflict)
286
+ end
287
+
288
+ other_sub = StandardId::SocialIdentity.where(identifier_id: identifier.id, provider: provider.provider_name).where.not(subject: subject)
289
+ return nil unless other_sub.lock.exists?
290
+
291
+ raise StandardId::SocialLinkConflictError.new(SOCIAL_RETRY_MESSAGE, identifier: identifier, reason: :subject_mismatch)
292
+ end
293
+
294
+ # The (provider, sub) link and the provider backfill are STAGED, not
295
+ # written, by find_or_create_account_from_social when the caller defers
296
+ # them (the web and API callbacks do), and written by commit_social_link!
297
+ # only once the login has been accepted. A rejected login therefore never
298
+ # writes a link, so rejecting it never has to delete one — and cannot
299
+ # delete a row a concurrent, successful callback for the same
300
+ # (provider, sub) has created or adopted in the meantime.
301
+ #
302
+ # Callers that do not defer (the default, e.g. host code calling
303
+ # find_or_create_account_from_social directly) get the link written
304
+ # immediately, as before.
305
+ def stage_social_link!(identifier, subject, backfill_provider:, emit_linked: false)
306
+ @pending_social_link = {
307
+ identifier: identifier,
308
+ subject: subject,
309
+ backfill_provider: backfill_provider,
310
+ emit_linked: emit_linked
311
+ }
312
+ commit_social_link! unless defer_social_link?
313
+ end
314
+
315
+ def defer_social_link?
316
+ false
317
+ end
318
+
319
+ # Writes the staged link in one transaction: the provider backfill and
320
+ # the (provider, sub) row land together or not at all, and the staged
321
+ # state is kept until they have, so a failure part-way leaves nothing to
322
+ # reverse. A block, when given, runs inside that transaction after the
323
+ # writes: work the login still has to get through for the link to stand
324
+ # (the web callback's redirect_to, which can raise). It runs even when
325
+ # nothing is staged. SOCIAL_ACCOUNT_LINKED is published only after the
326
+ # transaction has committed.
327
+ #
328
+ # The account row is locked first and must still exist. That serialises
329
+ # this commit with AccountCleanup.destroy_newly_created!, which takes the
330
+ # same lock: an account another, rejected request created and is now
331
+ # removing is never linked to (this login fails and can simply be
332
+ # retried, creating a fresh account), and an account this login has
333
+ # already signed in to is never removed under it (see AccountCleanup).
334
+ def commit_social_link!
335
+ pending = @pending_social_link
336
+ if pending.nil?
337
+ yield if block_given?
338
+ return
339
+ end
340
+
341
+ identifier = pending[:identifier]
342
+ begin
343
+ write_social_link!(pending, identifier) { yield if block_given? }
344
+ rescue StandardId::SocialLinkConflictError => e
345
+ # A concurrent login won the link (see the error's reasons).
346
+ # Published here, once the transaction has rolled back, so a
347
+ # subscriber that writes an audit row is not rolled back with the
348
+ # link; and only on this path, so never for a link that commits.
349
+ emit_social_link_blocked(identifier, provider, e.reason)
350
+ raise
351
+ end
352
+
353
+ @pending_social_link = nil
354
+ if pending[:backfill_provider]
355
+ identifier.provider = provider.provider_name
356
+ identifier.clear_attribute_changes([:provider]) if identifier.respond_to?(:clear_attribute_changes)
357
+ end
358
+ emit_committed_social_link(identifier, provider) if pending[:emit_linked]
359
+ end
360
+
361
+ # The transaction behind commit_social_link!.
362
+ def write_social_link!(pending, identifier)
363
+ ActiveRecord::Base.transaction do
364
+ if StandardId.account_class.lock.where(id: identifier.account_id).pick(:id).nil?
365
+ raise StandardId::InvalidGrantError, SOCIAL_RETRY_MESSAGE
366
+ end
367
+
368
+ if pending[:backfill_provider]
369
+ # Conditional, so a provider set concurrently is never overwritten.
370
+ StandardId::Identifier.where(id: identifier.id, provider: nil).update_all(provider: provider.provider_name)
371
+ end
372
+ record_social_identity!(identifier, pending[:subject])
373
+ yield if block_given?
374
+ end
375
+ end
376
+
377
+ # SOCIAL_ACCOUNT_LINKED reports a link that has already committed, so it
378
+ # cannot refuse the login: a subscriber that raises here would fail the
379
+ # callback while the link stays. Its error is logged and reported to
380
+ # Rails.error instead of propagating. (Guards belong on the events that
381
+ # run before anything is written.)
382
+ def emit_committed_social_link(identifier, provider)
383
+ emit_social_account_linked(identifier.account, provider, identifier)
384
+ rescue StandardError => e
385
+ Rails.logger&.error("[StandardId] SOCIAL_ACCOUNT_LINKED subscriber raised after the link committed: #{e.class}: #{e.message}")
386
+ Rails.error.report(e, handled: true, source: "standard_id")
387
+ end
388
+
389
+ # A social login that recorded a link (and maybe created an account) and
390
+ # was then rejected for ANY reason — policy, hook, invalid scope, audience
391
+ # binding, an unexpected error — must leave nothing behind: drop the link
392
+ # and remove an account this request created. Safe to call when nothing
393
+ # was recorded, and when the account is already gone.
394
+ #
395
+ # When the login matched an EXISTING account, `sessions` / `refresh_tokens`
396
+ # are what this request issued (and has revoked): if a concurrent, refused
397
+ # request kept that account only because this login had signed in to it,
398
+ # the account is removed after all (AccountCleanup.reclaim_for_failed_adopter!).
399
+ def discard_social_attempt!(account, newly_created:, sessions: [], refresh_tokens: [])
400
+ rollback_social_link!
401
+ if newly_created
402
+ StandardId::AccountCleanup.destroy_newly_created!(account)
403
+ else
404
+ StandardId::AccountCleanup.reclaim_for_failed_adopter!(account, sessions:, refresh_tokens:)
172
405
  end
173
406
  end
174
407
 
408
+ # The login matched an account by email that a concurrent, refused
409
+ # request had created and has since removed (AccountCleanup). The login
410
+ # then fails part-way (its session insert hits the foreign key, or
411
+ # commit_social_link! finds the account gone); the callbacks report that
412
+ # as a retryable invalid_grant rather than a 500. A retry finds no
413
+ # account and creates a fresh one. Check before discard_social_attempt!,
414
+ # which removes an account THIS request created.
415
+ def social_account_removed_concurrently?(account, newly_created:)
416
+ return false if newly_created || account.nil? || account.id.nil?
417
+
418
+ !StandardId.account_class.where(id: account.id).exists?
419
+ end
420
+
421
+ SOCIAL_RETRY_MESSAGE = "The sign-in could not be completed. Please try again.".freeze
422
+
423
+ # Nothing was written for a deferred link, so dropping the staged one is
424
+ # the whole rollback. (A non-deferring caller has already committed it.)
425
+ def rollback_social_link!
426
+ @pending_social_link = nil
427
+ end
428
+
175
429
  def social_identities_available?
176
430
  return true if StandardId::SocialIdentity.available?
177
431
 
@@ -8,7 +8,10 @@ module StandardId
8
8
 
9
9
  private
10
10
 
11
- def store_oauth_request(state:, nonce: nil, params:)
11
+ # `code_verifier` is the PKCE verifier for providers that opt in with
12
+ # `supports_pkce?`; like the nonce it stays server-held (the cookie is
13
+ # encrypted) and is handed back to the provider at the callback.
14
+ def store_oauth_request(state:, params:, nonce: nil, code_verifier: nil)
12
15
  pending_requests = load_pending_requests || {}
13
16
 
14
17
  cleanup_expired_requests!(pending_requests)
@@ -16,6 +19,7 @@ module StandardId
16
19
  pending_requests[state] = {
17
20
  "params" => params,
18
21
  "nonce" => nonce,
22
+ "code_verifier" => code_verifier,
19
23
  "expires_at" => REQUEST_EXPIRY.from_now.to_i
20
24
  }
21
25
 
@@ -43,7 +47,7 @@ module StandardId
43
47
  save_pending_requests(pending_requests)
44
48
  end
45
49
 
46
- request_data.slice("params", "nonce")
50
+ request_data.slice("params", "nonce", "code_verifier")
47
51
  rescue JSON::ParserError => e
48
52
  StandardId.logger.error({
49
53
  subject: "standard_id.consume_oauth_request.error",
@@ -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,
@@ -14,7 +14,7 @@ module StandardId
14
14
  # (UTM, campaign IDs, deep-link slugs) to the signing-in account.
15
15
  RESERVED_CALLBACK_PARAMS = %w[
16
16
  id_token code scope scopes audience redirect_uri flow
17
- state nonce provider controller action format
17
+ state nonce iss provider controller action format
18
18
  authenticity_token utf8 _method
19
19
  ].freeze
20
20
 
@@ -23,28 +23,78 @@ 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
+ issued = revoke_issued_tokens!(flow)
56
+ discard_social_attempt!(account, newly_created: newly_created, **issued)
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
+ #
83
+ # Returns what was revoked, as discard_social_attempt!'s `sessions:` /
84
+ # `refresh_tokens:`.
85
+ def revoke_issued_tokens!(flow)
86
+ record = flow&.issued_refresh_token
87
+ return { sessions: [], refresh_tokens: [] } unless record&.persisted?
88
+
89
+ # Loaded by id, not through the association: the record was built
90
+ # with only session_id, and a host with strict_loading_by_default
91
+ # would raise here and mask the original error.
92
+ session = record.session_id && StandardId::Session.find_by(id: record.session_id)
93
+ session.revoke!(reason: "social_sign_in_rejected") unless session.nil? || session.revoked?
94
+ record.revoke!
95
+ { sessions: [session].compact, refresh_tokens: [record] }
96
+ end
97
+
48
98
  # Mirror of the web callback's OAuthError handling: emit
49
99
  # SOCIAL_AUTH_FAILED for infrastructure-level provider failures
50
100
  # (HTTP/DNS/SSL/timeouts surfaced as OAuthError by provider
@@ -54,6 +104,12 @@ module StandardId
54
104
  # ...) are policy/client errors, not infrastructure failures, and
55
105
  # must not emit. The error re-raises into the standard
56
106
  # handle_oauth_error JSON response.
107
+ #
108
+ # The provider receives the request's `iss` as `callback_iss:` (as on
109
+ # the web callback) but never a `code_verifier:`: this endpoint has no
110
+ # server-held flow state, and a client-supplied `code_verifier` param
111
+ # is not forwarded. A provider that opts in to core-managed PKCE
112
+ # (supports_pkce?) gets no verifier here and should refuse.
57
113
  def fetch_provider_user_info
58
114
  get_user_info_from_provider(flow: provider.flow_for(params))
59
115
  rescue StandardId::OAuthError => e