standard_id 0.43.1 → 0.44.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: 8003e8ad18d3a88d9684bb3877d88bbc1d9d207a897776e6ef3a6cd01b0f1191
4
- data.tar.gz: 3fb388482c096695f36d79ee02e122e1cbede9fc1d11b14c6236a4d423a82a0b
3
+ metadata.gz: ea9ca4b9484333d3ed979bc9479b3ddcbde475a3d4f63e9903a0a4b437d925e7
4
+ data.tar.gz: 99b5c5993640de8c2634fbdd96fcacb95806a35c130d5b268d7daf51382c5275
5
5
  SHA512:
6
- metadata.gz: 2aade7f1bba3cb85827120d4ac19e55b0a2a039955097cd43af6ef75a84f1516080c4e6c0fc36a87d15731bb332603eab987c93130e69e2e2f5701a5ac929053
7
- data.tar.gz: c0a8bc153ac8db1de5f5186fb014e4ed89e0b5db06ef7e295ee276f032fc59ec39a591eb6e54e440263a22d43faf8b426f6e1acf4ab09fed72870e96c4e17f85
6
+ metadata.gz: a17095049de6adf0c32413fad6322a58b2f50b77c5db052043a74f082e40e6374518d22dad2518eeb4a01c15921865af993b51bae46447a8ee0135e968e6ec14
7
+ data.tar.gz: 2262c87410fd09684f885818aca1f31de00fe6f12daad4db0c6706521acf4d852ee2bdc623e15cc83da1aea9a7fbb7001eaab0129331ff93904056055cef4e9d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.44.0] - 2026-10-02
11
+
12
+ **Security fix (L1-01): social login could take over an existing account by email.** Behaviour change and a new migration; see **Upgrade notes**.
13
+
14
+ ### Security
15
+
16
+ - **Social logins are now matched on the provider's stable subject id (`sub`), and linking to an existing account requires a provider-verified email.** `SocialAuthentication#find_or_create_account_from_social` used to look up `EmailIdentifier.find_by(value: email)` and sign the login in to that account. It never looked at `sub`, and `email_verified` only decided whether a *new* identifier was marked verified. Under `link_strategy: :trust_provider`, or under the default `:strict` for any email identifier with a `NULL` provider (every identifier created before migration `20260311000000` added the column, and every identifier the gem creates outside social login: password signup, passwordless, `find_or_create_by_verified_email!`), a provider token that merely *claimed* an address, verified or not, signed in as that address's account. Both the web callback (`Web::Auth::Callback::ProvidersController`) and the API callback (`Api::Oauth::Callback::ProvidersController` → `Oauth::SocialFlow`) went through this method; `SocialFlow` and `SocialLoginGrant` themselves do no account lookup. Resolution is now:
17
+ 1. `(provider, sub)` matches a `StandardId::SocialIdentity` → that account, whatever email the provider now reports.
18
+ 2. The email matches an existing identifier → link only if the `link_strategy` allows it, the identifier is not already linked to a **different** `sub` from the same provider, and the provider reports `email_verified` as `true` or `"true"` (Apple and Google's tokeninfo send the string). This holds under `:trust_provider` too. A successful link stores the `sub`.
19
+ 3. Otherwise a new account is created exactly as before (an unverified provider email still creates an unverified identifier), and the `sub` is stored.
20
+
21
+ Each refusal emits `SOCIAL_LINK_BLOCKED` and raises `StandardId::SocialLinkError` instead of creating a duplicate account. The web callback already turns that error into a redirect to `/login` with an alert; the API callback returns `403 access_denied`.
22
+
23
+ ### Added
24
+
25
+ - **`standard_id_social_identities`** (migration `20261002000000_create_standard_id_social_identities`) and the `StandardId::SocialIdentity` model: `account_id`, `identifier_id` (the email identifier the link was made through), `provider`, `subject`, unique on `(provider, subject)` and on `(identifier_id, provider)`. Both foreign keys are `ON DELETE CASCADE`, so deleting an identifier or account needs no new step.
26
+ - `StandardId::SocialLinkError#reason`: `:link_required` (the strict strategy refused, as before), `:email_unverified` or `:subject_mismatch`. `SOCIAL_LINK_BLOCKED` carries the same `reason`. The constructor's new `reason:` keyword is optional and defaults to `:link_required`, so code that builds the error itself is unaffected.
27
+
28
+ ### Changed
29
+
30
+ - `email_verified` is read strictly: only `true` or `"true"` (any case) count. Google's OAuth2 v2 userinfo key `verified_email` is accepted when `email_verified` is absent, because standard_id-google ≤ 0.5.0 returns that endpoint's JSON unchanged on its code and access-token paths.
31
+
32
+ ### Upgrade notes
33
+
34
+ 1. **Install and run the migration**: `bin/rails standard_id:install:migrations && bin/rails db:migrate`. It creates one new, empty table; nothing existing is rewritten and nothing is backfilled. Existing social users get their row on their next login (on a verified email). Until it has run, the gem logs a warning once per process, skips subject matching, and still enforces the verified-email requirement; the boot-time missing-migration check and `StandardId::Checks::Migrations` also report it.
35
+ 2. **Upgrade standard_id-google to 0.6.0** alongside this release if you use it. Its code-exchange and access-token paths read Google's v2 userinfo endpoint, which returns `id` / `verified_email` rather than `sub` / `email_verified`, so on ≤ 0.5.0 those logins carry no `sub` (they still work, matched by verified email every time). 0.6.0 adds `sub` and `email_verified`. standard_id-apple already returns both from the verified ID token.
36
+ 3. **Expect refusals where a link used to go through silently.** A social login whose provider reports the email as unverified can no longer attach to an existing account (it previously did under `:trust_provider`, or for a `NULL`-provider identifier). Google and Apple report `email_verified: true` for the address on almost every account, so this should be rare; subscribe to `SOCIAL_LINK_BLOCKED` and read `reason` to see it. A host that builds custom copy from `SocialLinkError` may want per-`reason` messages.
37
+ 4. `link_strategy` keeps its meaning (`:strict` default, `:trust_provider`); no config change is needed.
38
+
39
+
40
+ ## [0.43.2] - 2026-09-25
41
+
42
+ ### Fixed
43
+
44
+ - **`CleanupExpiredSessionsJob` failed on every run once an expired session had a refresh token** (sidekick-web SIDEKICK-WEB-3K / cron monitor SIDEKICK-WEB-3M: `PG::ForeignKeyViolation … violates foreign key constraint "fk_rails_db44ba6f6e" on table "standard_id_refresh_tokens"`). `standard_id_refresh_tokens.session_id` references `standard_id_sessions` with no `ON DELETE` action, and `Session.where(...).delete_all` skipped the model's `dependent: :nullify`. This affected every host that schedules the job, directly or through `CleanupAllJob`, and nothing was ever cleaned up. Now, per batch of 1,000 (`batch_size:`), in one transaction, with the candidate rows re-checked under `FOR UPDATE SKIP LOCKED`:
45
+ - **An expired session that still has a live refresh token (unrevoked, unexpired) is kept.** Refresh tokens deliberately outlive their session's expiry: `RefreshTokenFlow#validate_parent_session!` checks revocation, not expiry, since 0.35.x, so `refresh_token_lifetime` alone decides how long a client stays signed in (jumpdrive-web runs 1-day browser sessions against 30-day refresh tokens). Deleting such a session would detach its token and lose "revoking this session ends its access". Revoking the token, as `Session#destroy` does, would sign the client out early. The session is collected on a later run, once its tokens are dead, so at most `refresh_token_lifetime` after it would otherwise have gone.
46
+ - **Dead refresh tokens (revoked or expired) of the sessions being deleted are detached** (`session_id` → `NULL`, as `dependent: :nullify` does) rather than deleted. `CleanupExpiredRefreshTokensJob` removes them on its own window, and until then a replayed revoked token still triggers reuse detection.
47
+ - A host whose copy of the migration added `on_delete: :nullify` to that FK (sidekick-web's `db/schema.rb` shows one, although its production constraint evidently has none) no longer has live tokens silently detached from their sessions either.
48
+ - The other cleanup jobs had no such hazard. Nothing references `standard_id_authorization_codes` or `standard_id_code_challenges`, and the only foreign key into `standard_id_refresh_tokens` is its own `previous_token_id`, which is `ON DELETE SET NULL`. `standard_id_refresh_tokens.session_id` is the only foreign key into `standard_id_sessions`, in the gem, standard_id-provider and all five apps.
49
+
50
+ ### Changed
51
+
52
+ - **Requires Rails 8.1** (`rails >= 8.1`, was `>= 8.0`; #344, merged since 0.43.1). All five consumer apps already run Rails 8.1.3.1, and 8.0 was never tested in CI.
53
+
54
+ ### Upgrade notes
55
+
56
+ Bump only. The first run after upgrading deletes, in batches, the backlog of expired sessions the job never managed to remove.
57
+
10
58
  ## [0.43.1] - 2026-09-24
11
59
 
12
60
  Fixes found while shipping 0.43.0. **Take this instead of 0.43.0** — hosts on `c.passwordless.delivery = :built_in` with the WebEngine mounted (jumpdrive-web, nutripod-web) would otherwise email email-verification codes as "your sign-in code".
data/README.md CHANGED
@@ -1165,6 +1165,30 @@ redirect_to "/api/authorize?" + {
1165
1165
  }.to_query
1166
1166
  ```
1167
1167
 
1168
+ #### How a social login finds its account (0.44+)
1169
+
1170
+ 1. **Provider + subject.** A login whose `(provider, sub)` is stored in
1171
+ `standard_id_social_identities` signs in to that account. The email the
1172
+ provider reports is not consulted.
1173
+ 2. **Existing email identifier.** Otherwise, if the email belongs to an
1174
+ existing account, the login links to it only when all of these hold, and
1175
+ raises `StandardId::SocialLinkError` (after emitting `SOCIAL_LINK_BLOCKED`)
1176
+ when one does not. The error's `reason` says which:
1177
+ - `link_strategy` allows it (`:link_required` under `:strict`);
1178
+ - the identifier is not already linked to a **different** `sub` from the
1179
+ same provider (`:subject_mismatch`);
1180
+ - the provider reports the email as verified: `email_verified` is `true`
1181
+ or the string `"true"` (`:email_unverified`). This applies under
1182
+ `:trust_provider` too.
1183
+
1184
+ A successful link stores the `sub`, so the next login matches on step 1.
1185
+ 3. **New account.** Otherwise a new account is created, as before, and the
1186
+ `sub` is stored.
1187
+
1188
+ Providers must return the OIDC `sub` and `email_verified` claims in
1189
+ `user_info`. A provider that returns no `sub` still works, but is matched on
1190
+ email (with the verified-email requirement) every time.
1191
+
1168
1192
  ### Passwordless Authentication
1169
1193
 
1170
1194
  ```ruby
@@ -1503,12 +1527,12 @@ StandardId never deletes expired rows on its own. Four cleanup jobs do, and **al
1503
1527
 
1504
1528
  | Job | Deletes | Grace windows (`perform` kwargs) | Recommended cadence |
1505
1529
  |---|---|---|---|
1506
- | `StandardId::CleanupExpiredSessionsJob` | browser/device/service sessions expired > grace | `grace_period_seconds:` 7 days | hourly (minute 6) |
1530
+ | `StandardId::CleanupExpiredSessionsJob` | browser/device/service sessions expired > grace and holding no live refresh token (dead tokens are detached, not deleted) | `grace_period_seconds:` 7 days; `batch_size:` 1,000 | hourly (minute 6) |
1507
1531
  | `StandardId::CleanupExpiredRefreshTokensJob` | refresh tokens expired or revoked > grace | `grace_period_seconds:` 7 days | hourly (minute 3) |
1508
1532
  | `StandardId::CleanupExpiredAuthorizationCodesJob` | OAuth authorization codes expired > 7 days or consumed > 1 day | `grace_period_seconds:`, `consumed_grace_period_seconds:` | hourly (minute 9) |
1509
1533
  | `StandardId::CleanupExpiredCodeChallengesJob` | OTP code challenges expired > 7 days or used > 1 day | `grace_period_seconds:`, `used_grace_period_seconds:` | hourly (minute 13) |
1510
1534
 
1511
- Retention is bounded by the grace windows, not the cadence; each job is a single `DELETE`, so running hourly keeps that statement small on busy tables (daily is fine for small apps). Stagger them off minute 0.
1535
+ Retention is bounded by the grace windows, not the cadence. Three of the jobs are a single `DELETE`, so running hourly keeps that statement small on busy tables (daily is fine for small apps). `CleanupExpiredSessionsJob` instead works in batches of `batch_size:` sessions: one transaction per batch, holding a `SELECT … FOR UPDATE SKIP LOCKED` on the candidates, an `UPDATE` detaching their dead refresh tokens and the `DELETE`. It never deletes a session whose refresh token is still live, because refresh tokens outlive session expiry by design; such a session goes on a later run, once its tokens are dead. Stagger the jobs off minute 0.
1512
1536
 
1513
1537
  `rails g standard_id:install` adds all four to `config/recurring.yml` (Solid Queue) under `production:` when that file exists (`--skip-recurring` to opt out; re-running is a no-op). An engine cannot register Solid Queue recurring tasks itself — Solid Queue reads one schedule file — so existing apps should paste this under their `production:` key:
1514
1538
 
@@ -31,6 +31,25 @@ module StandardId
31
31
  provider.get_user_info(**resolved_params.compact)
32
32
  end
33
33
 
34
+ # Resolves the account for a social login, in this order:
35
+ #
36
+ # 1. (provider, sub) matches a StandardId::SocialIdentity → that account.
37
+ # The provider's stable subject id is authoritative; the email it
38
+ # reports is not consulted.
39
+ # 2. The email matches an existing EmailIdentifier → link to that account,
40
+ # but only when
41
+ # - the link_strategy allows it (validate_social_link!),
42
+ # - the identifier is not already linked to a DIFFERENT sub from this
43
+ # provider (possible takeover), and
44
+ # - the provider reports the email as verified. Without that, the
45
+ # token proves nothing about who owns the address — under
46
+ # :trust_provider, or for a pre-provider-tracking identifier, any
47
+ # provider token for the address would otherwise take the account.
48
+ # A successful link stores the sub, so step 1 matches next time.
49
+ # 3. Otherwise create a new account (unchanged) and store the sub.
50
+ #
51
+ # Each refusal raises StandardId::SocialLinkError (with a `reason`) after
52
+ # emitting SOCIAL_LINK_BLOCKED; a duplicate account is never created.
34
53
  def find_or_create_account_from_social(raw_social_info)
35
54
  social_info = raw_social_info.to_h.with_indifferent_access
36
55
  email = social_info[:email]
@@ -38,11 +57,21 @@ module StandardId
38
57
 
39
58
  emit_social_user_info_fetched(provider, social_info, email)
40
59
 
60
+ subject = social_subject(social_info)
61
+ social_identity = find_social_identity(subject)
62
+ if social_identity
63
+ emit_social_account_linked(social_identity.account, provider, social_identity.identifier)
64
+ return social_identity.account
65
+ end
66
+
41
67
  identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: email)
42
68
 
43
69
  if identifier.present?
44
70
  validate_social_link!(identifier, provider)
71
+ validate_social_subject!(identifier, provider, subject)
72
+ validate_social_email_verified!(identifier, provider, social_info)
45
73
  identifier.update!(provider: provider.provider_name) if identifier.provider.nil?
74
+ record_social_identity!(identifier, subject)
46
75
  emit_social_account_linked(identifier.account, provider, identifier)
47
76
  identifier.account
48
77
  else
@@ -52,7 +81,8 @@ module StandardId
52
81
  value: email,
53
82
  provider: provider.provider_name
54
83
  )
55
- identifier.verify! if identifier.respond_to?(:verify!) && [true, "true"].include?(social_info[:email_verified])
84
+ identifier.verify! if identifier.respond_to?(:verify!) && social_email_verified?(social_info)
85
+ record_social_identity!(identifier, subject)
56
86
  emit_social_account_created(account, provider, social_info)
57
87
  account
58
88
  end
@@ -68,15 +98,95 @@ module StandardId
68
98
 
69
99
  return if strategy == :trust_provider
70
100
  # nil provider means the identifier predates provider tracking — allow
71
- # through since we can't retroactively determine its origin.
101
+ # through since we can't retroactively determine its origin. The
102
+ # email_verified check (validate_social_email_verified!) still applies.
72
103
  return if identifier.provider.nil?
73
104
  return if identifier.provider == provider.provider_name
74
105
  return if account_has_social_identifier_from?(identifier.account, provider)
75
106
 
76
- emit_social_link_blocked(identifier, provider)
107
+ refuse_social_link!(identifier, provider, :link_required)
108
+ end
109
+
110
+ # The identifier is already linked to another subject from this provider:
111
+ # a second provider account is claiming the same address.
112
+ def validate_social_subject!(identifier, provider, subject)
113
+ return if subject.nil?
114
+ return unless StandardId::SocialIdentity.available?
115
+
116
+ linked = StandardId::SocialIdentity.where(identifier_id: identifier.id, provider: provider.provider_name)
117
+ return unless linked.where.not(subject: subject).exists?
118
+
119
+ refuse_social_link!(identifier, provider, :subject_mismatch)
120
+ end
121
+
122
+ # Linking a login to an EXISTING account by email needs the provider to
123
+ # vouch for the address, under either link_strategy.
124
+ def validate_social_email_verified!(identifier, provider, social_info)
125
+ return if social_email_verified?(social_info)
126
+
127
+ refuse_social_link!(identifier, provider, :email_unverified)
128
+ end
129
+
130
+ def refuse_social_link!(identifier, provider, reason)
131
+ emit_social_link_blocked(identifier, provider, reason)
77
132
  raise StandardId::SocialLinkError.new(
78
133
  email: identifier.value,
79
- provider_name: provider.provider_name
134
+ provider_name: provider.provider_name,
135
+ reason: reason
136
+ )
137
+ end
138
+
139
+ # The provider's stable subject id (OIDC `sub`), or nil when the provider
140
+ # reports none.
141
+ def social_subject(social_info)
142
+ social_info[:sub].presence&.to_s
143
+ end
144
+
145
+ # Strict: only boolean true or the string "true" (any case) count. Apple
146
+ # sends `email_verified` as the string "true"; Google's tokeninfo endpoint
147
+ # does too. Google's OAuth2 v2 userinfo endpoint names the claim
148
+ # `verified_email` (standard_id-google <= 0.5.0 passes it through as-is),
149
+ # so it is accepted as a fallback.
150
+ def social_email_verified?(social_info)
151
+ value = social_info.key?(:email_verified) ? social_info[:email_verified] : social_info[:verified_email]
152
+ value.to_s.strip.casecmp?("true")
153
+ end
154
+
155
+ def find_social_identity(subject)
156
+ return nil if subject.nil?
157
+ return nil unless social_identities_available?
158
+
159
+ StandardId::SocialIdentity.includes(:account, :identifier).find_by(provider: provider.provider_name, subject: subject)
160
+ end
161
+
162
+ def record_social_identity!(identifier, subject)
163
+ return if subject.nil?
164
+ return unless social_identities_available?
165
+
166
+ StandardId::SocialIdentity.find_or_create_by!(
167
+ provider: provider.provider_name,
168
+ subject: subject
169
+ ) do |social_identity|
170
+ social_identity.account = identifier.account
171
+ social_identity.identifier = identifier
172
+ end
173
+ end
174
+
175
+ def social_identities_available?
176
+ return true if StandardId::SocialIdentity.available?
177
+
178
+ StandardId::SocialAuthentication.warn_social_identities_missing!
179
+ false
180
+ end
181
+
182
+ # Logged once per process when the host has not run the migration yet.
183
+ def self.warn_social_identities_missing!
184
+ return if @social_identities_missing_warned
185
+
186
+ @social_identities_missing_warned = true
187
+ Rails.logger&.warn(
188
+ "[StandardId] standard_id_social_identities is missing, so social logins are not matched on the " \
189
+ "provider's subject id. Run `bin/rails standard_id:install:migrations && bin/rails db:migrate`."
80
190
  )
81
191
  end
82
192
 
@@ -154,13 +264,14 @@ module StandardId
154
264
  )
155
265
  end
156
266
 
157
- def emit_social_link_blocked(identifier, provider)
267
+ def emit_social_link_blocked(identifier, provider, reason = :link_required)
158
268
  StandardId::Events.publish(
159
269
  StandardId::Events::SOCIAL_LINK_BLOCKED,
160
270
  email: identifier.value,
161
271
  provider: provider,
162
272
  identifier: identifier,
163
- account: identifier.account
273
+ account: identifier.account,
274
+ reason: reason
164
275
  )
165
276
  end
166
277
 
@@ -2,14 +2,70 @@ module StandardId
2
2
  class CleanupExpiredSessionsJob < ApplicationJob
3
3
  queue_as :default
4
4
 
5
+ DEFAULT_BATCH_SIZE = 1_000
6
+
5
7
  # Delete sessions that expired more than `grace_period_seconds` ago.
6
8
  # A grace period avoids deleting sessions that just expired and might
7
9
  # still be referenced in in-flight requests.
8
10
  # Accepts integer seconds for reliable ActiveJob serialization across all queue adapters.
9
- def perform(grace_period_seconds: 7.days.to_i)
11
+ #
12
+ # Refresh tokens reference their session (standard_id_refresh_tokens.session_id,
13
+ # a foreign key with no ON DELETE action in the gem's migration), and
14
+ # `delete_all` skips the model's `dependent: :nullify`, so a bare delete
15
+ # failed the whole run once any expired session had a refresh token
16
+ # (sidekick-web SIDEKICK-WEB-3K). Per batch, in one transaction:
17
+ #
18
+ # * An expired session that still has a LIVE refresh token (unrevoked,
19
+ # unexpired) is kept. Refresh tokens deliberately outlive their session's
20
+ # expiry — RefreshTokenFlow#validate_parent_session! checks revocation,
21
+ # not expiry, so refresh_token_lifetime alone governs how long a client
22
+ # stays signed in. Deleting the session would detach the token from it and
23
+ # lose "revoking this session ends its access"; revoking the token (what
24
+ # Session#destroy does) would sign the client out early. The session is
25
+ # collected on a later run, once its tokens are dead — at most
26
+ # refresh_token_lifetime later.
27
+ # * Dead refresh tokens (revoked or expired) of the sessions being deleted
28
+ # are detached (`session_id` → NULL, the model's `dependent: :nullify`),
29
+ # not deleted: CleanupExpiredRefreshTokensJob removes them on its own
30
+ # window, and until then a replayed revoked token still triggers reuse
31
+ # detection.
32
+ #
33
+ # Batches (`in_batches`) keep each transaction and its locks small.
34
+ def perform(grace_period_seconds: 7.days.to_i, batch_size: DEFAULT_BATCH_SIZE)
10
35
  cutoff = grace_period_seconds.seconds.ago
11
- deleted = StandardId::Session.where("expires_at < ?", cutoff).delete_all
36
+ deleted = 0
37
+
38
+ StandardId::Session.where("expires_at < ?", cutoff).in_batches(of: batch_size) do |batch|
39
+ deleted += delete_batch(batch.pluck(:id), cutoff)
40
+ end
41
+
12
42
  Rails.logger.info("[StandardId] Cleaned up #{deleted} expired sessions older than #{cutoff}")
13
43
  end
44
+
45
+ private
46
+
47
+ def delete_batch(candidate_ids, cutoff)
48
+ StandardId::Session.transaction do
49
+ # Re-check under a row lock: a sign-in may have revived one of these
50
+ # rows (OauthSessionPersistence reuses an expired, unrevoked device
51
+ # session and bumps expires_at) or issued it a refresh token since the
52
+ # batch was read. SKIP LOCKED leaves a row another transaction holds for
53
+ # the next run. (SQLite ignores the lock clause; it serialises writers.)
54
+ ids = StandardId::Session
55
+ .where(id: candidate_ids)
56
+ .where("expires_at < ?", cutoff)
57
+ .where.not(id: live_refresh_tokens.select(:session_id))
58
+ .lock("FOR UPDATE SKIP LOCKED")
59
+ .pluck(:id)
60
+ next 0 if ids.empty?
61
+
62
+ StandardId::RefreshToken.where(session_id: ids).update_all(session_id: nil)
63
+ StandardId::Session.where(id: ids).delete_all
64
+ end
65
+ end
66
+
67
+ def live_refresh_tokens
68
+ StandardId::RefreshToken.active.where.not(session_id: nil)
69
+ end
14
70
  end
15
71
  end
@@ -0,0 +1,30 @@
1
+ module StandardId
2
+ # A social provider's stable subject id (`sub`) linked to an account.
3
+ #
4
+ # Written by StandardId::SocialAuthentication when a social login creates an
5
+ # account, or links to an existing email identifier on a provider-verified
6
+ # email. Later logins with the same (provider, subject) resolve to the same
7
+ # account, whatever email the provider then reports.
8
+ #
9
+ # `identifier` is the email identifier the link was made through. A different
10
+ # subject from the same provider for that identifier is refused.
11
+ class SocialIdentity < ApplicationRecord
12
+ self.table_name = "standard_id_social_identities"
13
+
14
+ belongs_to :account, class_name: StandardId.config.account_class_name
15
+ belongs_to :identifier, class_name: "StandardId::Identifier"
16
+
17
+ validates :provider, presence: true
18
+ validates :subject, presence: true, uniqueness: { scope: :provider }
19
+ validates :identifier_id, uniqueness: { scope: :provider }
20
+
21
+ # Hosts that upgraded the gem but have not run
22
+ # 20261002000000_create_standard_id_social_identities yet. Cached by the
23
+ # schema cache, so this is one lookup per process.
24
+ def self.available?
25
+ table_exists?
26
+ rescue ActiveRecord::ActiveRecordError
27
+ false
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,44 @@
1
+ # Stores the social provider's stable subject id (`sub`) per account, so a
2
+ # returning social login is matched on (provider, sub) instead of on the email
3
+ # address alone.
4
+ #
5
+ # Before this table, `find_or_create_account_from_social` linked a provider
6
+ # login to whichever account held an email identifier with the same address,
7
+ # without looking at `sub` or `email_verified`. Under `link_strategy:
8
+ # :trust_provider`, or for an identifier with a NULL provider, any token that
9
+ # claimed an address could sign in as that address's account.
10
+ #
11
+ # New, empty table: nothing existing is rewritten, and no row is backfilled.
12
+ # Each existing social user gets a row on their next successful login (when the
13
+ # provider reports the email as verified). Until the migration has run, the
14
+ # gem skips subject matching and logs a warning, but still refuses to link an
15
+ # existing account to an unverified provider email.
16
+ #
17
+ # Both foreign keys cascade so the host's account- and identifier-deletion
18
+ # paths keep working unchanged: deleting the email identifier a link was made
19
+ # through removes the link, and the next login has to re-link on a verified
20
+ # email.
21
+ class CreateStandardIdSocialIdentities < ActiveRecord::Migration[8.0]
22
+ include StandardId::MigrationHelpers
23
+
24
+ def change
25
+ create_table :standard_id_social_identities, id: primary_key_type do |t|
26
+ t.references :account, type: foreign_key_type, null: false, index: true,
27
+ foreign_key: { to_table: StandardId.account_class.table_name, on_delete: :cascade }
28
+ t.references :identifier, type: foreign_key_type, null: false, index: false,
29
+ foreign_key: { to_table: :standard_id_identifiers, on_delete: :cascade }
30
+
31
+ t.string :provider, null: false
32
+ t.string :subject, null: false
33
+
34
+ t.timestamps
35
+ end
36
+
37
+ # One account per provider subject.
38
+ add_index :standard_id_social_identities, [:provider, :subject], unique: true
39
+ # One subject per provider for a given email identifier: a second subject
40
+ # claiming the same address is refused as a possible takeover.
41
+ add_index :standard_id_social_identities, [:identifier_id, :provider], unique: true,
42
+ name: "index_standard_id_social_identities_on_identifier_and_provider"
43
+ end
44
+ end
@@ -478,10 +478,14 @@ StandardId.configure do |c|
478
478
  # Default: []
479
479
  # c.social.available_scopes = %w[profile email offline_access]
480
480
 
481
- # Account linking strategy:
482
- # :strict — refuse to link a social identity unless the email
483
- # already matches a verified identifier (default)
484
- # :trust_provider — accept the social provider's claim and create or link
481
+ # Account linking strategy, for a social login whose (provider, sub) is not
482
+ # linked yet but whose email belongs to an existing account:
483
+ # :strict — link only if that email identifier came from the same
484
+ # provider, predates provider tracking, or the account
485
+ # already has an identifier from this provider (default)
486
+ # :trust_provider — link to the account that holds the email
487
+ # Under both, the provider must report the email as verified, and an email
488
+ # already linked to a different sub from the same provider is refused.
485
489
  # Default: :strict
486
490
  # c.social.link_strategy = :trust_provider
487
491
 
@@ -565,6 +565,10 @@ StandardId::ConfigSchema.define do
565
565
  field :social_account_attributes, type: :any, default: nil
566
566
  field :allowed_redirect_url_prefixes, type: :array, default: []
567
567
  field :available_scopes, type: :array, default: -> { [] }
568
+ # How a social login whose (provider, sub) is not linked yet may link to
569
+ # an existing account that holds its email: :strict or :trust_provider.
570
+ # Either way the provider must report the email as verified. See
571
+ # StandardId::SocialAuthentication#find_or_create_account_from_social.
568
572
  field :link_strategy, type: :symbol, default: :strict
569
573
  # What to do at boot when an enabled social provider is missing required
570
574
  # config (e.g. apple_client_id set without apple_private_key). :warn logs
@@ -154,12 +154,23 @@ module StandardId
154
154
  # NOTE: email and provider_name are exposed as reader attributes for host
155
155
  # apps to build custom error responses. If you report exceptions to an
156
156
  # error tracker (Sentry, etc.), be aware these attributes contain PII.
157
+ #
158
+ # `reason` says why the link was refused:
159
+ # :link_required — the email belongs to an account the strict
160
+ # link_strategy will not link this provider to
161
+ # :email_unverified — the provider did not report the email as verified,
162
+ # so it cannot prove ownership of the existing account
163
+ # :subject_mismatch — the account's email identifier is already linked to a
164
+ # different subject (`sub`) from this provider
157
165
  class SocialLinkError < OAuthError
158
- attr_reader :email, :provider_name
166
+ REASONS = %i[link_required email_unverified subject_mismatch].freeze
167
+
168
+ attr_reader :email, :provider_name, :reason
159
169
 
160
- def initialize(email:, provider_name:)
170
+ def initialize(email:, provider_name:, reason: :link_required)
161
171
  @email = email
162
172
  @provider_name = provider_name
173
+ @reason = reason
163
174
  super("This email is already associated with an account. Please sign in first to link this provider.")
164
175
  end
165
176
 
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.43.1"
2
+ VERSION = "0.44.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.43.1
4
+ version: 0.44.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '8.0'
18
+ version: '8.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '8.0'
25
+ version: '8.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: bcrypt
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -195,6 +195,7 @@ files:
195
195
  - app/models/standard_id/refresh_token.rb
196
196
  - app/models/standard_id/service_session.rb
197
197
  - app/models/standard_id/session.rb
198
+ - app/models/standard_id/social_identity.rb
198
199
  - app/models/standard_id/username_identifier.rb
199
200
  - app/views/standard_id/password_reset_mailer/reset_email.html.erb
200
201
  - app/views/standard_id/password_reset_mailer/reset_email.text.erb
@@ -235,6 +236,7 @@ files:
235
236
  - db/migrate/20260611000000_create_standard_id_client_grants.rb
236
237
  - db/migrate/20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications.rb
237
238
  - db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb
239
+ - db/migrate/20261002000000_create_standard_id_social_identities.rb
238
240
  - lib/generators/standard_id/install/install_generator.rb
239
241
  - lib/generators/standard_id/install/templates/standard_id.rb
240
242
  - lib/standard_id.rb
@@ -345,7 +347,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
345
347
  - !ruby/object:Gem::Version
346
348
  version: '0'
347
349
  requirements: []
348
- rubygems_version: 4.0.3
350
+ rubygems_version: 4.0.10
349
351
  specification_version: 4
350
352
  summary: A comprehensive authentication engine for Rails, built on the security primitives
351
353
  introduced in Rails 8.