standard_id 0.45.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7acc907df7682568d732664fb09873ab023b696e4057095b261a318aab7543b1
4
- data.tar.gz: f20929b44d2669f600e304345dbc34992fe9df54b9ff7e40fd64b45731a117e1
3
+ metadata.gz: 2a825be521399d2fbb91a820cbc0da601d24865a12fc549d1a8697ea1d6c97cb
4
+ data.tar.gz: da555da47e6a9ae99f1c888e5ef93c2fc4a02d21b11993300c67d02df6ef5d01
5
5
  SHA512:
6
- metadata.gz: ac1c4b0e6148140f310456a71fa9e88e162750aa35707b945c2b7c0061a19b1cae54a0cc2fb33b8b418d151c6bfd60190f49669c9cb14dedb159c6bb3b448602
7
- data.tar.gz: 99ddde4158e6354df56d377ed3508cb4111207492b62f12ffcb9c6276a862c0bd0e7cbbcc22e41a026b3073db3252325d6a354be903e3413bf5431ac8ea9f45d
6
+ metadata.gz: f54bf303a4cc32095b3117df9636b9f3e04ebe4483de5b79f513a0089b44d45a36a3ff7dbaf54d0858a4c29c657e11452f9e7b4ffe89e7418fbf55598ecf75a7
7
+ data.tar.gz: 157b2b4ffdca2f4a22eaf78207ba28579ef71c59693d6f8587159be19bd36d9540c008e9ede3bbbcc9f313f10d5ef762b9553e898428ffe4f37f7f513c83c2ef
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.46.0] - 2026-10-05
11
+
12
+ ### Added
13
+
14
+ - **Callback `iss` for providers (RFC 9207).** The web and API social callbacks pass the request's `iss` parameter to `get_user_info` as `callback_iss:` when it is a String, so a provider can refuse an authorization-server mix-up without hooking the callback controller itself. On the API callback, `iss` is no longer forwarded to `SOCIAL_AUTH_COMPLETED` subscribers as `original_request_params`, like the other OAuth-flow params.
15
+ - **Core-managed PKCE: `Providers::Base.supports_pkce?`** (default `false`). For a provider that returns `true`, `/login?connection=<provider>` generates a fresh verifier per sign-in, stores it with the state and nonce in the encrypted pending-requests cookie, and passes `code_challenge:` / `code_challenge_method: "S256"` to `authorization_url`. The web callback passes the stored verifier to `get_user_info` as `code_verifier:`, and refuses a callback whose stored request has none. The API callback never passes a verifier (it has no server-held flow state; a client-supplied `code_verifier` is not forwarded), and the API social login grant refuses a PKCE provider. `build_authorization_url` emits `code_challenge` / `code_challenge_method` when `options` carries them; `Providers::Base.pkce_s256_challenge(verifier)` computes the S256 challenge. Providers that do not opt in see no change: both new kwargs reach them only through `**options`, and nil values are not passed. See the README, "Callback `iss` and core-managed PKCE".
16
+
17
+ ### Fixed
18
+
19
+ - **Social-link races are classified correctly under MySQL/InnoDB `REPEATABLE READ`.** When a concurrent login committed a rival `(provider, sub)` or `(identifier, provider)` row after this login's lookup, the re-read that decides who won ran inside the link transaction as a plain `SELECT`. Under `REPEATABLE READ` that read the transaction's earlier snapshot, missed the rival, and re-raised the `RecordNotUnique`: a 500 with no `SOCIAL_LINK_BLOCKED` instead of a retryable `invalid_grant` (or adopting a same-account rival). `SocialAuthentication#classify_social_link_race!` now uses locking reads (`SELECT ... FOR UPDATE`), which always see the latest committed row. PostgreSQL (`READ COMMITTED`) already saw the rival and behaves as before; on SQLite the lock is a no-op.
20
+ - **A refused request's new account is reclaimed when the login it was kept for fails too.** Since 0.45.0, `AccountCleanup.destroy_newly_created!` keeps a refused request's new account when a concurrent login (B) has already signed in to it. If B then failed as well, B revoked its own session or refresh token but, not having created the account, removed nothing, so the account and its identifier were left behind with no way in and blocked a later signup for that address. The kept account now remembers the session and refresh-token ids it was kept for (in `StandardId.cache_store`, for one hour), and the social callbacks and `handle_authentication_denied` call the new **`AccountCleanup.reclaim_for_failed_adopter!`** with what the failing login issued. The account is removed, under the same row lock, only when one of those credentials was among the remembered ones and nothing else is signed in to it; if another login still is, the account is kept for that one instead. A login that succeeded, or any other later sign-in, never triggers the removal. This needs a cache store shared by all processes (e.g. Solid Cache or Redis); with a per-process store or `:null_store` nothing is remembered and the account is kept, as in 0.45.0.
21
+ - **`SOCIAL_ACCOUNT_LINKED` is no longer published for a link that was never recorded.** When `SocialAuthentication#record_social_identity!` found a `(provider, sub)` row that was then removed (for example by a concurrent account cleanup) before `classify_social_link_race!` re-read it, it returned without inserting: the provider backfill committed and `SOCIAL_ACCOUNT_LINKED` was published, yet no `(provider, sub)` row existed, so later logins were not matched by subject. The `sub` is free at that point, so the login now goes on to insert the row (any rival that commits meanwhile is classified as before), and the event is published only for a row that was inserted or adopted. Under MySQL/InnoDB `REPEATABLE READ` the uniqueness validations of that insert read the transaction's snapshot, which still holds the removed row, and raised `RecordInvalid`; when the locking re-read finds no rival, the insert is now retried once without validations (every other validation passed, and unique indexes back both uniqueness rules).
22
+
10
23
  ## [0.45.0] - 2026-10-04
11
24
 
12
25
  Two opt-in hooks that the org-IdP provider plugin (`standard_id-void_which_binds`) needs. With neither configured, behaviour is unchanged.
data/README.md CHANGED
@@ -1527,22 +1527,65 @@ end
1527
1527
  | `skip_csrf?` | `false` | `true` for POST (form_post) callbacks |
1528
1528
  | `supports_mobile_callback?` | `false` | Enables the server-side redirect back to a native app |
1529
1529
  | `trusted_for_linking?` | `false` | `true` lets `:strict` link to an account created via another provider (verified emails only). Org-owned IdPs only; see "Trusted linking" (0.45+) |
1530
+ | `supports_pkce?` | `false` | `true` makes core generate, store and hand back a PKCE (S256) verifier for the web flow; see "Callback `iss` and core-managed PKCE" (0.46+) |
1530
1531
 
1531
1532
  **Protected helpers** for use inside those methods — signatures are stable:
1532
1533
 
1533
1534
  | Helper | Does |
1534
1535
  |--------|------|
1535
1536
  | `build_response(user_info, tokens:)` | The standard `get_user_info` return value |
1536
- | `build_authorization_url(endpoint:, client_id:, redirect_uri:, state:, options: {}, defaults: {}, response_type: "code")` | `client_id`, `redirect_uri`, `response_type`, `state`, then each `supported_authorization_params` entry from `options` or `defaults`; nils dropped |
1537
+ | `build_authorization_url(endpoint:, client_id:, redirect_uri:, state:, options: {}, defaults: {}, response_type: "code")` | `client_id`, `redirect_uri`, `response_type`, `state`, then each `supported_authorization_params` entry from `options` or `defaults`, then `code_challenge` / `code_challenge_method` from `options`; nils dropped |
1537
1538
  | `extract_tokens(parsed_token)` | `{ access_token:, refresh_token:, id_token: }` from a token response, nils dropped |
1538
1539
  | `verify_nonce!(expected:, actual:)` | Constant-time nonce check; no-op when `expected` is blank. Raises `InvalidRequestError` without echoing either value |
1539
1540
  | `rescue_to_oauth_error(message_prefix = nil) { ... }` | Lets `StandardId::OAuthError` through; wraps anything else in one, keeping `cause` |
1541
+ | `pkce_s256_challenge(code_verifier)` (public) | `BASE64URL(SHA256(verifier))`, unpadded (RFC 7636 S256) |
1540
1542
 
1541
1543
  A plugin using `env:`, `required:` or these helpers should depend on
1542
1544
  `standard_id >= 0.42`. `Providers::Base.setup` is no longer called (removed
1543
1545
  from the base class in 0.42; the call-with-a-warning shim went in 0.43) — do
1544
1546
  one-off initialization in your own Railtie instead.
1545
1547
 
1548
+ #### Callback `iss` and core-managed PKCE (0.46+)
1549
+
1550
+ Core hands two more values to `get_user_info`, so a provider does not have to
1551
+ capture or derive them itself. Both arrive through `**options`; a provider
1552
+ that does not name them ignores them, and a nil value is never passed.
1553
+
1554
+ - **`callback_iss:`** — the callback's `iss` parameter (RFC 9207), when the
1555
+ redirect carried one as a String. Compare it with the issuer you expect
1556
+ before exchanging the code (authorization-server mix-up defence). It is
1557
+ passed on both the web callback and `/api/oauth/callback/:provider` (where
1558
+ it is whatever the client relayed); refuse a missing one if your IdP always
1559
+ sends it.
1560
+ - **`code_verifier:`** — for a provider whose `supports_pkce?` returns `true`.
1561
+ On every `/login?connection=<provider>` core generates a fresh verifier,
1562
+ stores it with the state and nonce in the encrypted pending-requests cookie
1563
+ (it never appears in a URL), and passes `code_challenge:` and
1564
+ `code_challenge_method: "S256"` to `authorization_url`
1565
+ (`build_authorization_url` emits both). At the web callback the stored
1566
+ verifier comes back as `code_verifier:`; send it in the token request. A
1567
+ web callback whose stored request has no verifier (a state issued for
1568
+ another provider, or one started before the provider opted in) is refused
1569
+ before the provider is called.
1570
+
1571
+ ```ruby
1572
+ def self.supports_pkce? = true
1573
+
1574
+ def self.get_user_info(code: nil, redirect_uri: nil, nonce: nil, callback_iss: nil, code_verifier: nil, **_options)
1575
+ raise StandardId::InvalidRequestError, "issuer mismatch" unless callback_iss == EXPECTED_ISSUER
1576
+ raise StandardId::InvalidRequestError, "web sign-in only" if code_verifier.blank?
1577
+
1578
+ # POST code, redirect_uri and code_verifier to the token endpoint ...
1579
+ end
1580
+ ```
1581
+
1582
+ The API paths keep no server-side flow state, so they never pass a
1583
+ `code_verifier:` (a client-supplied `code_verifier` param is not forwarded),
1584
+ and the API social login grant (`/authorize?connection=...`) refuses to start
1585
+ a sign-in for a provider that `supports_pkce?` rather than send it without a
1586
+ challenge. A PKCE provider is therefore web-only unless it handles a native
1587
+ flow itself.
1588
+
1546
1589
  **Testing a plugin, or an app that uses one:**
1547
1590
 
1548
1591
  ```ruby
@@ -191,8 +191,16 @@ module StandardId
191
191
  # @param account [Object, nil] the account to clean up if newly created
192
192
  # @param newly_created [Boolean] whether the account was created during this request
193
193
  def handle_authentication_denied(error, account: nil, newly_created: false)
194
+ # Only a session THIS request created can have kept a refused new
195
+ # account alive (see AccountCleanup.reclaim_for_failed_adopter!), never
196
+ # one the browser already had.
197
+ created_session = session_manager.created_session if session_manager.respond_to?(:created_session)
194
198
  session_manager.revoke_current_session! if session_manager.current_session.present?
195
- destroy_newly_created_account(account) if newly_created
199
+ if newly_created
200
+ destroy_newly_created_account(account)
201
+ elsif account && created_session
202
+ StandardId::AccountCleanup.reclaim_for_failed_adopter!(account, sessions: [created_session])
203
+ end
196
204
  message = error.message
197
205
  # When raised without arguments, StandardError#message returns the class name
198
206
  message = "Sign-in was denied" if message.blank? || message == error.class.name
@@ -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.
@@ -193,13 +206,21 @@ module StandardId
193
206
  # (identifier, provider) collision into RecordNotFound, and return a
194
207
  # (provider, sub) winner for ANY account). Every one of them is classified
195
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.
196
215
  def record_social_identity!(identifier, subject)
197
216
  return if subject.nil?
198
217
  return unless social_identities_available?
199
218
 
200
219
  attributes = { provider: provider.provider_name, subject: subject }
201
- existing = StandardId::SocialIdentity.find_by(attributes)
202
- return classify_social_link_race!(identifier, subject) if existing
220
+ if StandardId::SocialIdentity.find_by(attributes)
221
+ winner = classify_social_link_race!(identifier, subject)
222
+ return winner if winner
223
+ end
203
224
 
204
225
  StandardId::SocialIdentity.transaction(requires_new: true) do
205
226
  StandardId::SocialIdentity.create!(attributes.merge(account: identifier.account, identifier: identifier))
@@ -209,6 +230,22 @@ module StandardId
209
230
  rescue ActiveRecord::RecordInvalid => e
210
231
  raise unless social_link_race_invalid?(e.record)
211
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
212
249
  classify_social_link_race!(identifier, subject) || raise
213
250
  end
214
251
 
@@ -229,8 +266,19 @@ module StandardId
229
266
  # (:subject_conflict) or this identifier to this provider under ANOTHER
230
267
  # sub (:subject_mismatch); returns nil when no rival is found (it was
231
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.
232
280
  def classify_social_link_race!(identifier, subject)
233
- winner = StandardId::SocialIdentity.find_by(provider: provider.provider_name, subject: subject)
281
+ winner = StandardId::SocialIdentity.lock.find_by(provider: provider.provider_name, subject: subject)
234
282
  if winner
235
283
  return winner if winner.account_id == identifier.account_id
236
284
 
@@ -238,7 +286,7 @@ module StandardId
238
286
  end
239
287
 
240
288
  other_sub = StandardId::SocialIdentity.where(identifier_id: identifier.id, provider: provider.provider_name).where.not(subject: subject)
241
- return nil unless other_sub.exists?
289
+ return nil unless other_sub.lock.exists?
242
290
 
243
291
  raise StandardId::SocialLinkConflictError.new(SOCIAL_RETRY_MESSAGE, identifier: identifier, reason: :subject_mismatch)
244
292
  end
@@ -343,9 +391,18 @@ module StandardId
343
391
  # binding, an unexpected error — must leave nothing behind: drop the link
344
392
  # and remove an account this request created. Safe to call when nothing
345
393
  # was recorded, and when the account is already gone.
346
- def discard_social_attempt!(account, newly_created:)
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: [])
347
400
  rollback_social_link!
348
- StandardId::AccountCleanup.destroy_newly_created!(account) if newly_created
401
+ if newly_created
402
+ StandardId::AccountCleanup.destroy_newly_created!(account)
403
+ else
404
+ StandardId::AccountCleanup.reclaim_for_failed_adopter!(account, sessions:, refresh_tokens:)
405
+ end
349
406
  end
350
407
 
351
408
  # The login matched an account by email that a concurrent, refused
@@ -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",
@@ -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
 
@@ -52,8 +52,8 @@ module StandardId
52
52
  commit_social_link!
53
53
  rescue StandardError
54
54
  removed = social_account_removed_concurrently?(account, newly_created:)
55
- revoke_issued_tokens!(flow)
56
- discard_social_attempt!(account, newly_created: newly_created)
55
+ issued = revoke_issued_tokens!(flow)
56
+ discard_social_attempt!(account, newly_created: newly_created, **issued)
57
57
  raise unless removed
58
58
 
59
59
  # The matched account was removed under this login by a
@@ -79,9 +79,12 @@ module StandardId
79
79
  # AccountCleanup does not mistake them for a concurrent login using a
80
80
  # new account. A token whose transaction rolled back is not persisted
81
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:`.
82
85
  def revoke_issued_tokens!(flow)
83
86
  record = flow&.issued_refresh_token
84
- return unless record&.persisted?
87
+ return { sessions: [], refresh_tokens: [] } unless record&.persisted?
85
88
 
86
89
  # Loaded by id, not through the association: the record was built
87
90
  # with only session_id, and a host with strict_loading_by_default
@@ -89,6 +92,7 @@ module StandardId
89
92
  session = record.session_id && StandardId::Session.find_by(id: record.session_id)
90
93
  session.revoke!(reason: "social_sign_in_rejected") unless session.nil? || session.revoked?
91
94
  record.revoke!
95
+ { sessions: [session].compact, refresh_tokens: [record] }
92
96
  end
93
97
 
94
98
  # Mirror of the web callback's OAuthError handling: emit
@@ -100,6 +104,12 @@ module StandardId
100
104
  # ...) are policy/client errors, not infrastructure failures, and
101
105
  # must not emit. The error re-raises into the standard
102
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.
103
113
  def fetch_provider_user_info
104
114
  get_user_info_from_provider(flow: provider.flow_for(params))
105
115
  rescue StandardId::OAuthError => e
@@ -25,10 +25,11 @@ module StandardId
25
25
  state_data = nil
26
26
 
27
27
  begin
28
- extract_state_and_nonce => { state_data:, nonce: }
28
+ extract_state_and_nonce => { state_data:, nonce:, code_verifier: }
29
+ code_verifier = pkce_verifier_for_provider!(code_verifier)
29
30
  caller_redirect_uri = state_data&.dig("redirect_uri").presence
30
31
  redirect_uri = callback_url_for
31
- provider_response = get_user_info_from_provider(redirect_uri:, nonce:)
32
+ provider_response = get_user_info_from_provider(redirect_uri:, nonce:, code_verifier:)
32
33
  social_info = provider_response[:user_info]
33
34
  provider_tokens = provider_response[:tokens]
34
35
  begin
@@ -143,7 +144,7 @@ module StandardId
143
144
  created.revoke!(reason: "social_sign_in_rejected") unless created.revoked?
144
145
  session_manager.clear_session!
145
146
  end
146
- discard_social_attempt!(account, newly_created: newly_created)
147
+ discard_social_attempt!(account, newly_created: newly_created, sessions: [created])
147
148
  end
148
149
 
149
150
  # Write the (provider, sub) link only once the login is accepted.
@@ -168,10 +169,24 @@ module StandardId
168
169
 
169
170
  {
170
171
  state_data: oauth_state["params"],
171
- nonce: oauth_state["nonce"]
172
+ nonce: oauth_state["nonce"],
173
+ code_verifier: oauth_state["code_verifier"].presence
172
174
  }
173
175
  end
174
176
 
177
+ # Core-managed PKCE (Providers::Base.supports_pkce?): the provider
178
+ # receives the verifier stored with this flow's state, and only
179
+ # then. A PKCE provider's flow must have been started with one (by
180
+ # /login for this provider), so a missing verifier — a state issued
181
+ # for another provider, or stored before the provider opted in — is
182
+ # refused rather than the code exchanged without it.
183
+ def pkce_verifier_for_provider!(code_verifier)
184
+ return nil unless provider.supports_pkce?
185
+ raise StandardId::InvalidRequestError, "Missing PKCE verifier for this sign-in" if code_verifier.nil?
186
+
187
+ code_verifier
188
+ end
189
+
175
190
  def handle_callback_error
176
191
  error_message = case params[:error]
177
192
  when "access_denied"
@@ -133,10 +133,12 @@ module StandardId
133
133
 
134
134
  state = generate_oauth_token
135
135
  nonce = provider_supports_nonce?(provider) ? generate_oauth_token : nil
136
+ code_verifier = provider.supports_pkce? ? generate_oauth_token : nil
136
137
 
137
138
  store_oauth_request(
138
139
  state:,
139
140
  nonce:,
141
+ code_verifier:,
140
142
  params: extract_social_login_params
141
143
  )
142
144
 
@@ -146,6 +148,12 @@ module StandardId
146
148
  # Add nonce to OAuth params if provider supports it
147
149
  extra_params[:nonce] = nonce if nonce.present?
148
150
 
151
+ # Core-managed PKCE: only the S256 challenge leaves the server.
152
+ if code_verifier
153
+ extra_params[:code_challenge] = provider.pkce_s256_challenge(code_verifier)
154
+ extra_params[:code_challenge_method] = "S256"
155
+ end
156
+
149
157
  url = provider.authorization_url(
150
158
  state:,
151
159
  redirect_uri: callback_url,
@@ -22,9 +22,25 @@ module StandardId
22
22
  # first and the account stays, or the removal goes first and the other
23
23
  # login fails and can be retried (creating a fresh account). Nothing waits
24
24
  # on a doomed account, and nothing signs in to one.
25
+ #
26
+ # An account kept that way is kept FOR the adopters it found: the session
27
+ # and refresh token ids that were active, remembered in
28
+ # StandardId.cache_store. When such an adopter's own request then fails
29
+ # too, it revokes what it issued and calls reclaim_for_failed_adopter!,
30
+ # which removes the account after all unless it is still adopted (by
31
+ # someone else, who is then remembered in turn). Only a failing request
32
+ # that issued one of the remembered credentials can trigger the removal,
33
+ # so an adopter that went on to succeed is never undone by a later,
34
+ # unrelated failure. With a cache store that does not share entries
35
+ # across processes (or the null store) nothing is remembered and the kept
36
+ # account stays, as before.
25
37
  module AccountCleanup
26
38
  module_function
27
39
 
40
+ # How long a kept account remembers its adopters: comfortably longer than
41
+ # the adopting request can take to finish.
42
+ KEPT_FOR_ADOPTERS_TTL = 1.hour
43
+
28
44
  # @return [Boolean] true when the account was removed, false when it was
29
45
  # already gone or another login has adopted it.
30
46
  def destroy_newly_created!(account)
@@ -34,20 +50,41 @@ module StandardId
34
50
  next false if account.class.lock.where(id: account.id).pick(:id).nil?
35
51
 
36
52
  if adopted?(account)
53
+ remember_adopters!(account)
37
54
  Rails.logger&.info("[StandardId] Kept refused new account #{account.id}: a concurrent sign-in is using it")
38
55
  next false
39
56
  end
40
57
 
41
- # Tokens first: refresh_tokens.account_id has no ON DELETE action, and
42
- # a refused API grant may already have issued one for this account.
43
- StandardId::RefreshToken.where(account_id: account.id).delete_all
44
- account.sessions.strict_loading(false).destroy_all
45
- identifiers = account.identifiers.strict_loading(false).to_a
46
- identifiers.each { |i| i.credentials.strict_loading(false).destroy_all }
47
- # Deliberately destroy! (unlike the destroy_all calls above): a failed identifier destroy raises and rolls back the whole cleanup, failing loud instead of leaving a half-cleaned orphan.
48
- identifiers.each(&:destroy!)
49
- account.destroy
50
- true
58
+ remove!(account)
59
+ end
60
+ end
61
+
62
+ # Called by a sign-in that matched an EXISTING account and then failed,
63
+ # with the sessions / refresh tokens it issued (already revoked). When a
64
+ # refused request kept that account only because of this sign-in (see
65
+ # destroy_newly_created!), the account is removed after all, unless
66
+ # something else has adopted it since. Anything else is left alone.
67
+ #
68
+ # @return [Boolean] true when the account was removed.
69
+ def reclaim_for_failed_adopter!(account, sessions: [], refresh_tokens: [])
70
+ return false unless account&.persisted?
71
+
72
+ issued = credential_keys(Array(sessions).compact.map(&:id), Array(refresh_tokens).compact.map(&:id))
73
+ return false if issued.empty?
74
+
75
+ ActiveRecord::Base.transaction do
76
+ next false if account.class.lock.where(id: account.id).pick(:id).nil?
77
+
78
+ kept_for = Array(read_kept_for(account))
79
+ next false if (kept_for & issued).empty?
80
+
81
+ if adopted?(account)
82
+ remember_adopters!(account)
83
+ next false
84
+ end
85
+
86
+ Rails.logger&.info("[StandardId] Removing refused new account #{account.id}: the sign-in it was kept for failed")
87
+ remove!(account)
51
88
  end
52
89
  end
53
90
 
@@ -58,5 +95,53 @@ module StandardId
58
95
 
59
96
  StandardId::RefreshToken.active.where(account_id: account.id).exists?
60
97
  end
98
+
99
+ def remove!(account)
100
+ # Tokens first: refresh_tokens.account_id has no ON DELETE action, and
101
+ # a refused API grant may already have issued one for this account.
102
+ StandardId::RefreshToken.where(account_id: account.id).delete_all
103
+ account.sessions.strict_loading(false).destroy_all
104
+ identifiers = account.identifiers.strict_loading(false).to_a
105
+ identifiers.each { |i| i.credentials.strict_loading(false).destroy_all }
106
+ # Deliberately destroy! (unlike the destroy_all calls above): a failed identifier destroy raises and rolls back the whole cleanup, failing loud instead of leaving a half-cleaned orphan.
107
+ identifiers.each(&:destroy!)
108
+ account.destroy
109
+ forget_kept_for(account)
110
+ true
111
+ end
112
+
113
+ # Remembers the adopters the account is being kept for. Best effort: the
114
+ # cache is not part of the transaction, and a cache failure only means
115
+ # the kept account is not reclaimed later.
116
+ def remember_adopters!(account)
117
+ session_ids = account.sessions.strict_loading(false).active.pluck(:id)
118
+ token_ids = StandardId::RefreshToken.active.where(account_id: account.id).pluck(:id)
119
+ StandardId.cache_store&.write(kept_for_key(account), credential_keys(session_ids, token_ids), expires_in: KEPT_FOR_ADOPTERS_TTL)
120
+ rescue StandardError => e
121
+ Rails.logger&.warn("[StandardId] Could not remember the adopters of kept account #{account.id}: #{e.class}: #{e.message}")
122
+ end
123
+
124
+ def read_kept_for(account)
125
+ StandardId.cache_store&.read(kept_for_key(account))
126
+ rescue StandardError => e
127
+ Rails.logger&.warn("[StandardId] Could not read the adopters of kept account #{account.id}: #{e.class}: #{e.message}")
128
+ nil
129
+ end
130
+
131
+ def forget_kept_for(account)
132
+ StandardId.cache_store&.delete(kept_for_key(account))
133
+ rescue StandardError
134
+ nil
135
+ end
136
+
137
+ def kept_for_key(account)
138
+ "standard_id:account_cleanup:kept_for:#{account.class.name}:#{account.id}"
139
+ end
140
+
141
+ def credential_keys(session_ids, refresh_token_ids)
142
+ session_ids.map { |id| "session:#{id}" } + refresh_token_ids.map { |id| "refresh_token:#{id}" }
143
+ end
144
+
145
+ private_class_method :remove!, :remember_adopters!, :read_kept_for, :forget_kept_for, :kept_for_key, :credential_keys
61
146
  end
62
147
  end
@@ -12,6 +12,12 @@ module StandardId
12
12
  @social_provider_url ||= begin
13
13
  connection = params[:connection]
14
14
  provider = StandardId::ProviderRegistry.get(connection)
15
+ # This flow's state is a stateless encoding, so there is nowhere
16
+ # server-held to keep a PKCE verifier. Refuse a provider that
17
+ # requires core-managed PKCE rather than start it without one.
18
+ if provider.supports_pkce?
19
+ raise StandardId::InvalidRequestError, "#{connection} sign-in requires the web login flow (PKCE)"
20
+ end
15
21
 
16
22
  provider.authorization_url(
17
23
  state: encode_state_with_original_params,
@@ -1,4 +1,6 @@
1
1
  require "uri"
2
+ require "base64"
3
+ require "digest"
2
4
  require "active_support/security_utils"
3
5
 
4
6
  module StandardId
@@ -81,7 +83,14 @@ module StandardId
81
83
  # @param id_token [String, nil] JWT ID token (mobile/implicit flow)
82
84
  # @param access_token [String, nil] Access token (implicit flow)
83
85
  # @param redirect_uri [String, nil] Original redirect_uri for code exchange validation
84
- # @param options [Hash] Provider-specific options (client_id for Apple mobile, etc.)
86
+ # @param options [Hash] Provider-specific options (client_id for Apple mobile, etc.).
87
+ # Core may also pass, and a provider that does not need them ignores:
88
+ # - `nonce:` the nonce the server issued for this flow (only for
89
+ # providers listing :nonce in {supported_authorization_params});
90
+ # - `callback_iss:` the callback's RFC 9207 `iss` parameter, when the
91
+ # redirect carried one as a String (absent otherwise);
92
+ # - `code_verifier:` the PKCE verifier core generated and stored with
93
+ # the flow's state (web callback only, and only when {supports_pkce?}).
85
94
  # @return [HashWithIndifferentAccess] Standardized response with user_info and tokens
86
95
  # @raise [NotImplementedError] if not overridden by subclass
87
96
  # @raise [StandardId::InvalidRequestError] if credentials are missing or invalid
@@ -246,6 +255,42 @@ module StandardId
246
255
  false
247
256
  end
248
257
 
258
+ # Whether core manages PKCE (RFC 7636, S256) for this provider's web
259
+ # sign-in.
260
+ #
261
+ # Default: false — core neither sends a challenge nor passes a verifier.
262
+ #
263
+ # When true, the web login flow generates a fresh `code_verifier` for
264
+ # every sign-in, stores it server-held alongside the state and nonce
265
+ # (the encrypted pending-requests cookie; it is never sent to the
266
+ # provider or put in a URL), and:
267
+ # - passes `code_challenge:` (the S256 challenge) and
268
+ # `code_challenge_method: "S256"` to {authorization_url}
269
+ # ({build_authorization_url} emits both when present);
270
+ # - passes the stored `code_verifier:` to {get_user_info} at the web
271
+ # callback, which the provider sends in its token request. A web
272
+ # callback whose stored request has no verifier is refused before
273
+ # the provider is called.
274
+ #
275
+ # The API callback (`/api/oauth/callback/:provider`) has no server-held
276
+ # flow state, so it never passes a `code_verifier:`; the API social
277
+ # login grant refuses to start a sign-in for a provider that returns
278
+ # true here rather than send it without a challenge.
279
+ #
280
+ # @return [Boolean]
281
+ def supports_pkce?
282
+ false
283
+ end
284
+
285
+ # The RFC 7636 S256 code challenge for a verifier:
286
+ # BASE64URL(SHA256(verifier)) without padding.
287
+ #
288
+ # @param code_verifier [String]
289
+ # @return [String]
290
+ def pkce_s256_challenge(code_verifier)
291
+ Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier.to_s), padding: false)
292
+ end
293
+
249
294
  # Returns list of supported authorization parameters for this provider.
250
295
  #
251
296
  # Include :nonce in this list for OIDC providers to enable nonce validation.
@@ -453,7 +498,10 @@ module StandardId
453
498
  #
454
499
  # Emits `client_id`, `redirect_uri`, `response_type`, `state`, then one
455
500
  # entry per {supported_authorization_params}, taking the caller's value
456
- # from `options` or falling back to `defaults`. Nil values are dropped.
501
+ # from `options` or falling back to `defaults`, then `code_challenge` and
502
+ # `code_challenge_method` when `options` carries them (core passes them
503
+ # only to providers that opt in with {supports_pkce?}). Nil values are
504
+ # dropped.
457
505
  #
458
506
  # @param endpoint [String] Provider authorization endpoint
459
507
  # @param client_id [String]
@@ -486,6 +534,9 @@ module StandardId
486
534
  query[param] = options[param] || defaults[param]
487
535
  end
488
536
 
537
+ query[:code_challenge] = options[:code_challenge]
538
+ query[:code_challenge_method] = options[:code_challenge_method]
539
+
489
540
  "#{endpoint}?#{URI.encode_www_form(query.compact)}"
490
541
  end
491
542
 
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.45.0"
2
+ VERSION = "0.46.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.45.0
4
+ version: 0.46.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim