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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +44 -1
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +9 -1
- data/app/controllers/concerns/standard_id/social_authentication.rb +65 -8
- data/app/controllers/concerns/standard_id/web/social_login_params.rb +6 -2
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +14 -4
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +19 -4
- data/app/controllers/standard_id/web/login_controller.rb +8 -0
- data/lib/standard_id/account_cleanup.rb +95 -10
- data/lib/standard_id/oauth/subflows/social_login_grant.rb +6 -0
- data/lib/standard_id/providers/base.rb +53 -2
- data/lib/standard_id/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a825be521399d2fbb91a820cbc0da601d24865a12fc549d1a8697ea1d6c97cb
|
|
4
|
+
data.tar.gz: da555da47e6a9ae99f1c888e5ef93c2fc4a02d21b11993300c67d02df6ef5d01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
data/lib/standard_id/version.rb
CHANGED