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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +167 -3
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +12 -19
- data/app/controllers/concerns/standard_id/social_authentication.rb +267 -13
- data/app/controllers/concerns/standard_id/web/social_login_params.rb +6 -2
- 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 +72 -16
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +69 -6
- data/app/controllers/standard_id/web/consent_controller.rb +5 -1
- data/app/controllers/standard_id/web/login_controller.rb +8 -0
- data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
- data/app/controllers/standard_id/web/signup_controller.rb +6 -1
- 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 +13 -0
- data/lib/standard_id/account_cleanup.rb +147 -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 +14 -0
- data/lib/standard_id/errors.rb +55 -0
- 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/social_login_grant.rb +6 -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 +87 -2
- 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 +5 -1
|
@@ -18,19 +18,32 @@ module StandardId
|
|
|
18
18
|
raise StandardId::InvalidRequestError, e.message
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
+
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
|