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 +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +26 -2
- data/app/controllers/concerns/standard_id/social_authentication.rb +117 -6
- data/app/jobs/standard_id/cleanup_expired_sessions_job.rb +58 -2
- data/app/models/standard_id/social_identity.rb +30 -0
- data/db/migrate/20261002000000_create_standard_id_social_identities.rb +44 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +8 -4
- data/lib/standard_id/config/schema.rb +4 -0
- data/lib/standard_id/errors.rb +13 -2
- data/lib/standard_id/version.rb +1 -1
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea9ca4b9484333d3ed979bc9479b3ddcbde475a3d4f63e9903a0a4b437d925e7
|
|
4
|
+
data.tar.gz: 99b5c5993640de8c2634fbdd96fcacb95806a35c130d5b268d7daf51382c5275
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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!) &&
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
483
|
-
#
|
|
484
|
-
#
|
|
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
|
data/lib/standard_id/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/standard_id/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|