standard_id 0.47.0 → 0.48.1
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 +29 -0
- data/README.md +83 -1
- data/app/controllers/concerns/standard_id/rate_limit_handling.rb +3 -1
- data/app/controllers/standard_id/api/base_controller.rb +5 -0
- data/app/controllers/standard_id/api/oauth/introspections_controller.rb +37 -3
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +4 -1
- data/app/models/standard_id/client_application.rb +9 -0
- data/app/models/standard_id/refresh_token.rb +28 -0
- data/app/views/standard_id/web/auth/callback/providers/mobile_callback.html.erb +4 -1
- data/db/migrate/20261020000000_add_client_application_id_to_standard_id_refresh_tokens.rb +23 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +4 -0
- data/lib/standard_id/account_status.rb +48 -7
- data/lib/standard_id/config/schema.rb +49 -0
- data/lib/standard_id/errors.rb +34 -0
- data/lib/standard_id/oauth/refresh_token_flow.rb +61 -0
- data/lib/standard_id/oauth/token_grant_flow.rb +18 -1
- data/lib/standard_id/otp.rb +13 -1
- data/lib/standard_id/passwordless/base_strategy.rb +61 -9
- data/lib/standard_id/rate_limiter.rb +35 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cdf22579e98c4ceb592cb20766d510d937d0e5d2f7f70c41074f984ddc3b0393
|
|
4
|
+
data.tar.gz: 35fbb21a24d40a307bd55279a86fa0d91549641e30f30d4101d667d9c5d7c793
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd202ffabe7287cb374442e6aeee83d0f2853a17d9cb7654ba816f28f48c4d6970b859b7d4d83581f09ef4a2793689b5fc620b545246bfb515691fda9a4cd1dd
|
|
7
|
+
data.tar.gz: 0e17bfff08af26fc343dedaf0d9500bbef69fe118031c305bdc29f33ede7231c8641c14b2b6156395702c4957c58b605dc6a24b54b4f975fa19618c8f4cade01
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.48.1] - 2026-10-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`StandardId::RetryDelayError`** (an `InvalidRequestError`, with `retry_after` seconds) is what the OTP resend cooldown (`passwordless.retry_delay`) now raises, and **`Otp.issue` reports it as `error_code: :cooldown`** instead of the generic `:invalid_request`. A host that wraps `Otp.issue` can tell "a usable code was issued moments ago" apart from a real failure and keep its own cooldown copy — which is what fundbright-web needed before it could drop its advisory lock around `Otp.issue` now that the cooldown check is atomic (0.48.0). Every existing `rescue StandardId::InvalidRequestError` keeps working.
|
|
15
|
+
|
|
16
|
+
## [0.48.0] - 2026-10-20
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Refresh tokens are bound to their `ClientApplication`**, and the `refresh_token` grant refuses a token whose application has been deactivated or deleted. Migration `20261020000000_add_client_application_id_to_standard_id_refresh_tokens` adds the nullable column; each grant records the application its `client_id` resolved to (authorization_code, client_credentials, any grant whose `client_id` names one) and every rotation copies it to the successor. The refusal is the same 400 `invalid_grant` / "Refresh token is no longer valid" every other dead token gets, so a leaked token learns nothing from it. Deactivation suspends (reactivating the application restores its tokens, with one deliberate exception: a replayed, already-rotated token still trips reuse detection and revokes its family, suspended or not); deletion is final. A row minted before the migration ran is bound lazily on its next rotation by `client_id`, and refused meanwhile if an application with that `client_id` exists and is inactive. A token whose `client_id` is not a registered application (social sign-in refreshes as the provider's name) is untouched. `/oauth/introspect` reports a refresh token bound to a gone or deactivated application as inactive. Replaces the host-side prepend sidekick-web carried as `config/initializers/standard_id_refresh_client_guard.rb` (core-platform-brain#490), which can be deleted once this is installed. `ClientApplication#refresh_tokens` is the new association; there is deliberately no foreign key, so a deleted application stays detectable from the token's side.
|
|
21
|
+
- **`c.inactive_account_status`** (symbol, default `:inactive`): the enum key `StandardId::AccountStatus` declares for a deactivated account, and so its stored value and predicate. A host whose status column already says `"suspended"` sets `:suspended` and adopts the concern — `suspended?`, the `suspended` scope, `deactivate!`, and the OAUTH_TOKEN_ISSUING / SESSION_CREATING / SESSION_VALIDATING guard (which then raises `AccountDeactivatedError, "Account is suspended"`) — without renaming a database enum value under running processes. `inactive?` stays as an alias; `:active` itself is refused with a `ConfigurationError` at include time. `activate!` / `deactivate!` stamp `activated_at` / `deactivated_at` only when the columns exist. Replaces the hand-rolled subscriber fundbright-web carries in its initializer (fundbright/delivery-ops#438).
|
|
22
|
+
- **`c.oauth.introspection_liveness`** (callable, default nil) and **`c.oauth.introspection_claims`** (array, default `[]`): `/oauth/introspect` runs the host's own liveness check after the gem's (signature, expiry, refresh-token row, parent session, bound ClientApplication) and renders the bare `{"active": false}` on a falsy answer or an exception (logged); and echoes the named extra claims (`profile_type`, `gid`, …) on an active response, standard members winning. Lets sidekick-web delete its app-owned RFC 7662 controller (187 lines + spec), which existed to re-check the token's profile row and return those two claims.
|
|
23
|
+
- **`ClientApplication.live`**: the scope form of `#active?` (`active` AND no `deactivated_at`), now what `RefreshToken#client_application_live?` queries, so there is one definition of a live application.
|
|
24
|
+
- **Lib-level OTP start limits**: `c.rate_limits.otp_start_per_target` (default 5 per 15 minutes, keyed per realm + channel + address) and `c.rate_limits.otp_start_per_ip` (per hour, default 0 = off) are enforced inside `Passwordless::BaseStrategy#start!`, so they hold for a host that drives `generate_passwordless_otp`, `StandardId::Otp.issue` or the strategy from its own controllers, operations or jobs — paths the engine's controller-level limits never see. Counted on the same store as those (`StandardId::RateLimiter::STORE`, which `RateLimitHandling::RATE_LIMIT_STORE` now aliases). A tripped limit raises the new `StandardId::RateLimitExceededError` (an `InvalidRequestError`; 429 `rate_limit_exceeded` with `Retry-After` from the API error handler, a flash on the web login page, `error_code: :rate_limited` from `Otp.issue`). Raise it alongside any controller per-target limit you raise, or it becomes the binding one. The per-target default equals the API's existing per-target limit, so an API caller sees no change; the per-IP limit is opt-in because the right ceiling depends on how many real users share an address.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **The OTP resend cooldown is atomic.** `BaseStrategy#start!` now runs the `retry_delay` check and the challenge insert in one transaction, serialised per (realm, channel, address) with a PostgreSQL transaction-scoped advisory lock (a no-op on other adapters). Two concurrent starts for one address used to both pass the check and both issue, and creating the second code invalidated the first, so the user held a dead one (fundbright delivery-ops#345; fundbright-web wraps `Otp.issue` in its own advisory lock for this and can stop). `PASSWORDLESS_CODE_REQUESTED` is published once that transaction has committed (still before `PASSWORDLESS_CODE_GENERATED`), so a subscriber's own writes cannot be rolled back with a failed insert; the flip side is that a subscriber which raises now leaves the user with an invalidated previous code and no new one delivered, so subscribers to this event should not raise.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **The mobile bounce (`POST /auth/callback_mobile/:provider`) no longer corrupts every relayed key after the first.** Its page wrote the redirect URL into a JS string with `j()`, which ERB then HTML-escaped, so the browser navigated to `?code=…&state=…` and each later key (`state`, `user`, `id_token`, …) arrived as `amp;<key>`. The URL is now emitted with `to_json`.
|
|
33
|
+
- **The mobile bounce relays `error`.** Apple posts `error=user_cancelled_authorize` (and no code) when the user backs out; the app now receives it, so it can tell a cancel from a failure.
|
|
34
|
+
|
|
35
|
+
### Upgrade
|
|
36
|
+
|
|
37
|
+
- Run `bin/rails standard_id:install:migrations && bin/rails db:migrate`. Until the column exists the gem neither writes nor reads the binding, and the boot-time migration check names it. Deactivated applications are refused by `client_id` lookup even before the migration; deleted ones need the column. Tokens minted before the migration whose application had **already been deleted** cannot be told from a provider-named token and keep rotating, unbound, until `refresh_token_lifetime` expires them; revoke their sessions by hand if that is too long.
|
|
38
|
+
|
|
10
39
|
## [0.47.0] - 2026-10-19
|
|
11
40
|
|
|
12
41
|
### Added
|
data/README.md
CHANGED
|
@@ -207,6 +207,18 @@ StandardId.configure do |config|
|
|
|
207
207
|
end
|
|
208
208
|
```
|
|
209
209
|
|
|
210
|
+
Refresh tokens are **bound to the `ClientApplication`** their grant resolved
|
|
211
|
+
(`standard_id_refresh_tokens.client_application_id`, since 0.48). Deactivating
|
|
212
|
+
the application suspends every refresh token it was issued — the grant answers
|
|
213
|
+
the generic `invalid_grant` — and reactivating it restores them (a replayed,
|
|
214
|
+
already-rotated token still trips reuse detection meanwhile and revokes its
|
|
215
|
+
family, as it always did); deleting the application ends them for good, which
|
|
216
|
+
is what makes RFC 7592 `DELETE` on a dynamically registered client an actual
|
|
217
|
+
revocation. A token whose `client_id`
|
|
218
|
+
is not a registered application (social sign-in refreshes as the provider's
|
|
219
|
+
name) has no application to share a fate with and is left alone. There is no
|
|
220
|
+
setting: this is how the grant behaves once the migration has run.
|
|
221
|
+
|
|
210
222
|
Under `:grant`, revoking a **stateless access token is a no-op** — this engine
|
|
211
223
|
has no per-access-token store, so it cannot invalidate one before its `exp`
|
|
212
224
|
(the response is still `200`, per RFC 7009 §2.2). Clients that need revocation
|
|
@@ -217,6 +229,40 @@ a wrong hint must not change the outcome.
|
|
|
217
229
|
`ServiceSession`s are never touched by `/oauth/revoke` under either setting —
|
|
218
230
|
machine credentials have their own lifecycle.
|
|
219
231
|
|
|
232
|
+
### Token Introspection (RFC 7662)
|
|
233
|
+
|
|
234
|
+
`POST /oauth/introspect` is off by default (`config.oauth.introspection_enabled`);
|
|
235
|
+
once on, confidential clients can ask whether a token is live. The gem checks
|
|
236
|
+
the signature and expiry, and for a refresh token also its row, its parent
|
|
237
|
+
session and the `ClientApplication` it is bound to. Two settings let the host
|
|
238
|
+
add what only it knows:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
StandardId.configure do |config|
|
|
242
|
+
config.oauth.introspection_enabled = true
|
|
243
|
+
|
|
244
|
+
# Run after the gem's checks pass. Takes any subset of payload:,
|
|
245
|
+
# refresh_token: (nil for an access token) and request:. Falsy — or an
|
|
246
|
+
# exception, which is logged — renders {"active": false}.
|
|
247
|
+
config.oauth.introspection_liveness = ->(payload:) {
|
|
248
|
+
next true if payload[:gid].blank? # a token with no host row to check (client_credentials)
|
|
249
|
+
|
|
250
|
+
profile = GlobalID::Locator.locate(payload[:gid])
|
|
251
|
+
profile.present? && profile.active?
|
|
252
|
+
rescue ActiveRecord::RecordNotFound # the row was deleted: inactive, and no log line needed
|
|
253
|
+
false
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
# Extra claims echoed VERBATIM on an ACTIVE response, beyond the RFC 7662
|
|
257
|
+
# members — list scalar claims. Absent claims are omitted; the standard
|
|
258
|
+
# members cannot be overridden.
|
|
259
|
+
config.oauth.introspection_claims = %w[profile_type gid]
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Access tokens are stateless, so a revoked session's access token still
|
|
264
|
+
introspects as active until its `exp`; keep `access_token_lifetime` short.
|
|
265
|
+
|
|
220
266
|
### Authenticating Opaque Session Tokens
|
|
221
267
|
|
|
222
268
|
`Session.by_token` is a **lookup**, not authentication: it matches the SHA256
|
|
@@ -717,7 +763,7 @@ Every StandardId event automatically carries tracing metadata (`event_id`, `time
|
|
|
717
763
|
| | `oauth.token.issued` | `access_token_id`, `grant_type`, `client_id`, `account`, `expires_in` | After tokens are generated |
|
|
718
764
|
| | `oauth.token.refreshed` | `old_token_id`, `new_token_id`, `client_id`, `account` | After a refresh token is redeemed |
|
|
719
765
|
| | `oauth.code.consumed` | `authorization_code`, `client_id`, `account` | After an authorization code is exchanged |
|
|
720
|
-
| Passwordless | `passwordless.code.requested` | `identifier`, `channel` (email/sms) |
|
|
766
|
+
| Passwordless | `passwordless.code.requested` | `identifier`, `channel` (email/sms), `realm` | After the challenge row is committed, before `code.generated` / delivery. Subscribers should not raise: the previous code is already invalidated by then |
|
|
721
767
|
| | `passwordless.code.generated` | `code_challenge`, `identifier`, `channel`, `expires_at` | After an OTP is created |
|
|
722
768
|
| | `passwordless.code.sent` | `identifier`, `channel`, `realm`, `delivery_status` | After an OTP is handed to delivery (not for `Otp.issue(delivery: :manual)`). `delivery_status`: `"sent"` (built-in mailer enqueued it), `"failed"` (built-in mailer was responsible but did not enqueue), `"delegated"` (a host `PASSWORDLESS_CODE_GENERATED` subscriber delivers — the engine can't confirm it) |
|
|
723
769
|
| | `passwordless.code.verified` | `code_challenge`, `account`, `channel` | After OTP verification succeeds |
|
|
@@ -932,6 +978,15 @@ end
|
|
|
932
978
|
|
|
933
979
|
The concern works with both PostgreSQL enum and string columns - Rails enum handles both transparently.
|
|
934
980
|
|
|
981
|
+
If your column already carries a different value for a deactivated account,
|
|
982
|
+
keep it: `c.inactive_account_status = :suspended` (set in the initializer,
|
|
983
|
+
before the model loads) makes the concern declare `{ active: "active",
|
|
984
|
+
suspended: "suspended" }`, so `suspended?`, the `suspended` scope and
|
|
985
|
+
`deactivate!` all use your word, and the sign-in / token / live-session guard
|
|
986
|
+
raises `AccountDeactivatedError, "Account is suspended"`. `inactive?` remains as
|
|
987
|
+
an alias. `activated_at` / `deactivated_at` are stamped only when the columns
|
|
988
|
+
exist.
|
|
989
|
+
|
|
935
990
|
### Usage
|
|
936
991
|
|
|
937
992
|
```ruby
|
|
@@ -1684,6 +1739,33 @@ bundle exec rspec spec/controllers/
|
|
|
1684
1739
|
rejected with `invalid_request`.
|
|
1685
1740
|
- Rate limiting on authentication endpoints
|
|
1686
1741
|
|
|
1742
|
+
## OTP Start Limits
|
|
1743
|
+
|
|
1744
|
+
The engine's controllers throttle their own OTP endpoints (`rate_limits.api_passwordless_start_*`,
|
|
1745
|
+
`login_per_*`, `verification_start_*`). A host that drives the passwordless strategy from its own
|
|
1746
|
+
controllers, an operation or a job (`generate_passwordless_otp`, `StandardId::Otp.issue`) never
|
|
1747
|
+
passes those, so two limits are enforced inside the strategy itself, on the same store:
|
|
1748
|
+
|
|
1749
|
+
```ruby
|
|
1750
|
+
StandardId.configure do |config|
|
|
1751
|
+
config.rate_limits.otp_start_per_target = 5 # per (realm, channel, address), per 15 minutes; 0 = off
|
|
1752
|
+
config.rate_limits.otp_start_per_ip = 0 # per hour; off by default — pick a ceiling that fits CGNAT/office traffic
|
|
1753
|
+
end
|
|
1754
|
+
```
|
|
1755
|
+
|
|
1756
|
+
A tripped limit raises `StandardId::RateLimitExceededError` (an `InvalidRequestError` that the API
|
|
1757
|
+
error handler renders as `429 rate_limit_exceeded` with `Retry-After`, the web login page shows as a
|
|
1758
|
+
flash, and `Otp.issue` returns as a failed result with `error_code: :rate_limited`). The per-target
|
|
1759
|
+
default matches the API's `api_passwordless_start_per_target`; if you raise that (or `login_per_email`)
|
|
1760
|
+
raise `otp_start_per_target` with it, or it becomes the binding limit.
|
|
1761
|
+
|
|
1762
|
+
The resend cooldown (`passwordless.retry_delay`) is reported separately from those limits:
|
|
1763
|
+
`StandardId::RetryDelayError` (an `InvalidRequestError` carrying `retry_after` seconds), which
|
|
1764
|
+
`Otp.issue` returns as `error_code: :cooldown`, so a host can tell "a code is already on its way"
|
|
1765
|
+
apart from a failure. The 30-second resend
|
|
1766
|
+
cooldown (`passwordless.retry_delay`) and the challenge insert run in one transaction, serialised per
|
|
1767
|
+
address with a PostgreSQL advisory lock, so two concurrent starts cannot both issue a code.
|
|
1768
|
+
|
|
1687
1769
|
## Missing Migrations
|
|
1688
1770
|
|
|
1689
1771
|
`standard_id:install:migrations` copies the engine's migrations with new timestamps, so a gem migration that was never copied is invisible to Rails' own pending-migration check. StandardId checks for that itself, by migration name:
|
|
@@ -2,7 +2,9 @@ module StandardId
|
|
|
2
2
|
module RateLimitHandling
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
# The one store object the engine counts on, shared with the lib-level
|
|
6
|
+
# limits (StandardId::RateLimiter) so `c.cache_store` governs both.
|
|
7
|
+
RATE_LIMIT_STORE = StandardId::RateLimiter::STORE
|
|
6
8
|
|
|
7
9
|
# Fallback window for the Retry-After header when the tripped limit's window
|
|
8
10
|
# was not captured on the instance — e.g. a hand-rolled `raise
|
|
@@ -91,6 +91,11 @@ module StandardId
|
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
def handle_oauth_error(error)
|
|
94
|
+
# A lib-level rate limit (StandardId::RateLimiter) knows its window;
|
|
95
|
+
# say so, as the controller-level limits do.
|
|
96
|
+
if error.respond_to?(:retry_after) && error.retry_after.to_i.positive?
|
|
97
|
+
response.set_header("Retry-After", error.retry_after.to_i.to_s)
|
|
98
|
+
end
|
|
94
99
|
render json: {
|
|
95
100
|
error: error.oauth_error_code,
|
|
96
101
|
error_description: error.message
|
|
@@ -105,6 +105,12 @@ module StandardId
|
|
|
105
105
|
end
|
|
106
106
|
return render_inactive if persisted && !persisted.active?
|
|
107
107
|
return render_inactive if persisted&.session && !persisted.session.active?
|
|
108
|
+
# ...and whose ClientApplication is gone or deactivated, which the
|
|
109
|
+
# refresh grant refuses too (#validate_client_application_liveness!).
|
|
110
|
+
return render_inactive if persisted && !persisted.client_application_live?
|
|
111
|
+
# ...and whatever the host knows that the gem cannot (a claim that
|
|
112
|
+
# points at a host row which has since been deactivated).
|
|
113
|
+
return render_inactive unless host_liveness?(payload, persisted)
|
|
108
114
|
|
|
109
115
|
render json: active_response(payload, persisted), status: :ok
|
|
110
116
|
end
|
|
@@ -155,10 +161,30 @@ module StandardId
|
|
|
155
161
|
nil
|
|
156
162
|
end
|
|
157
163
|
|
|
164
|
+
# `config.oauth.introspection_liveness`, when set. Called with the
|
|
165
|
+
# subset of payload:/refresh_token:/request: its signature accepts.
|
|
166
|
+
# Anything but a truthy answer — including an exception, which is
|
|
167
|
+
# logged — is inactive: a check that cannot answer must not say yes.
|
|
168
|
+
def host_liveness?(payload, persisted)
|
|
169
|
+
callable = StandardId.config.oauth.introspection_liveness
|
|
170
|
+
return true unless callable.respond_to?(:call)
|
|
171
|
+
|
|
172
|
+
context = { payload: payload, refresh_token: persisted, request: request }
|
|
173
|
+
!!callable.call(**StandardId::Utils::CallableParameterFilter.filter(callable, context))
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
# StandardId.logger, not config.logger: the latter is nil unless the
|
|
176
|
+
# host set it, and a broken hook that silently turns every token
|
|
177
|
+
# inactive would otherwise look like an outage with no signal.
|
|
178
|
+
StandardId.logger&.error("[StandardId] oauth.introspection_liveness raised: #{e.class}: #{e.message}")
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
|
|
158
182
|
# RFC 7662 §2.2. `.compact` so absent claims are omitted rather than
|
|
159
|
-
# emitted as null.
|
|
183
|
+
# emitted as null. Host-configured extra claims
|
|
184
|
+
# (`config.oauth.introspection_claims`) come first so the standard
|
|
185
|
+
# members always win.
|
|
160
186
|
def active_response(payload, persisted)
|
|
161
|
-
|
|
187
|
+
extra_claims(payload).merge(
|
|
162
188
|
active: true,
|
|
163
189
|
token_type: persisted ? "refresh_token" : "Bearer",
|
|
164
190
|
sub: payload[:sub],
|
|
@@ -169,7 +195,15 @@ module StandardId
|
|
|
169
195
|
jti: payload[:jti],
|
|
170
196
|
client_id: payload[:client_id],
|
|
171
197
|
scope: payload[:scope]
|
|
172
|
-
|
|
198
|
+
).compact
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def extra_claims(payload)
|
|
202
|
+
names = Array(StandardId.config.oauth.introspection_claims).map(&:to_s)
|
|
203
|
+
return {} if names.empty?
|
|
204
|
+
|
|
205
|
+
claims = payload.to_h.with_indifferent_access
|
|
206
|
+
names.to_h { |name| [name.to_sym, claims[name]] }.compact
|
|
173
207
|
end
|
|
174
208
|
|
|
175
209
|
def render_inactive
|
|
@@ -210,8 +210,11 @@ module StandardId
|
|
|
210
210
|
redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: redirect_uri), alert: error_message
|
|
211
211
|
end
|
|
212
212
|
|
|
213
|
+
# `error` is relayed so the app can tell a cancel from a failure: Apple
|
|
214
|
+
# posts `error=user_cancelled_authorize` (with the state) when the user
|
|
215
|
+
# backs out, and no code.
|
|
213
216
|
def mobile_relay_params
|
|
214
|
-
params.permit(:code, :state, :user, :userIdentifier, :id_token, :identity_token, :nonce).to_h.compact
|
|
217
|
+
params.permit(:code, :state, :user, :userIdentifier, :id_token, :identity_token, :nonce, :error).to_h.compact
|
|
215
218
|
end
|
|
216
219
|
|
|
217
220
|
def build_mobile_redirect(destination, extra_params)
|
|
@@ -16,6 +16,10 @@ module StandardId
|
|
|
16
16
|
|
|
17
17
|
has_many :client_secret_credentials, dependent: :destroy
|
|
18
18
|
has_many :authorization_codes, foreign_key: :client_id, primary_key: :client_id, dependent: :destroy
|
|
19
|
+
# Refresh tokens issued to this application (migration 20261020000000). Not
|
|
20
|
+
# a dependent association: rows outlive a deleted application on purpose,
|
|
21
|
+
# so the refresh_token grant and introspection can see that it is gone.
|
|
22
|
+
has_many :refresh_tokens, class_name: "StandardId::RefreshToken", inverse_of: :client_application
|
|
19
23
|
|
|
20
24
|
accepts_nested_attributes_for :client_secret_credentials, allow_destroy: false
|
|
21
25
|
|
|
@@ -46,6 +50,11 @@ module StandardId
|
|
|
46
50
|
|
|
47
51
|
# Scopes
|
|
48
52
|
scope :active, -> { where(active: true) }
|
|
53
|
+
# The scope form of #active?: `active` AND no `deactivated_at`. The two
|
|
54
|
+
# fields are kept in step by the before_update callback, but a row where
|
|
55
|
+
# they drift (update_columns, a data fix) must read the same from a query
|
|
56
|
+
# as from the instance — the refresh grant and introspection rely on it.
|
|
57
|
+
scope :live, -> { where(active: true, deactivated_at: nil) }
|
|
49
58
|
scope :confidential, -> { where(client_type: "confidential") }
|
|
50
59
|
scope :public_clients, -> { where(client_type: "public") }
|
|
51
60
|
scope :for_owner, ->(owner) { where(owner: owner) }
|
|
@@ -5,6 +5,12 @@ module StandardId
|
|
|
5
5
|
belongs_to :account, class_name: StandardId.config.account_class_name
|
|
6
6
|
belongs_to :session, class_name: "StandardId::Session", optional: true
|
|
7
7
|
belongs_to :previous_token, class_name: "StandardId::RefreshToken", optional: true
|
|
8
|
+
# The ClientApplication this token was issued to, when the grant's client_id
|
|
9
|
+
# named one (migration 20261020000000). NULL for tokens whose client_id is
|
|
10
|
+
# not an application (social sign-in refreshes as the provider's name) and
|
|
11
|
+
# for rows minted before the column existed. No FK on purpose: a deleted
|
|
12
|
+
# application has to stay detectable as "gone" (see #client_application_live?).
|
|
13
|
+
belongs_to :client_application, class_name: "StandardId::ClientApplication", optional: true
|
|
8
14
|
|
|
9
15
|
scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
|
|
10
16
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
|
@@ -25,6 +31,28 @@ module StandardId
|
|
|
25
31
|
!revoked? && !expired?
|
|
26
32
|
end
|
|
27
33
|
|
|
34
|
+
# Whether the host has run migration 20261020000000. Until then the gem
|
|
35
|
+
# neither writes nor reads the binding.
|
|
36
|
+
def self.client_binding_column?
|
|
37
|
+
column_names.include?("client_application_id")
|
|
38
|
+
rescue ActiveRecord::ActiveRecordError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# false once the application this token is bound to has been deactivated
|
|
43
|
+
# or deleted. An unbound token (NULL, or the column not yet migrated) is
|
|
44
|
+
# live by this measure: there is no application whose fate it shares.
|
|
45
|
+
#
|
|
46
|
+
# `ClientApplication.live` is the scope form of #active? (`active` AND no
|
|
47
|
+
# `deactivated_at`), so this and the refresh grant can never disagree
|
|
48
|
+
# about a row whose two fields drifted apart.
|
|
49
|
+
def client_application_live?
|
|
50
|
+
return true unless self.class.client_binding_column?
|
|
51
|
+
return true if client_application_id.nil?
|
|
52
|
+
|
|
53
|
+
StandardId::ClientApplication.live.exists?(id: client_application_id)
|
|
54
|
+
end
|
|
55
|
+
|
|
28
56
|
def expired?
|
|
29
57
|
expires_at <= Time.current
|
|
30
58
|
end
|
|
@@ -34,7 +34,10 @@
|
|
|
34
34
|
</style>
|
|
35
35
|
<script>
|
|
36
36
|
document.addEventListener("DOMContentLoaded", function () {
|
|
37
|
-
|
|
37
|
+
<%# to_json, not j(): ERB HTML-escapes the j() output, so every "&" in the
|
|
38
|
+
query became "&" and each key after the first arrived as "amp;<key>".
|
|
39
|
+
to_json emits a JS string literal with & < > as \u escapes. %>
|
|
40
|
+
var target = <%= raw @mobile_redirect_url.to_json %>;
|
|
38
41
|
if (target) {
|
|
39
42
|
window.location.replace(target);
|
|
40
43
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Binds each refresh token to the ClientApplication it was issued to, so the
|
|
2
|
+
# refresh_token grant can refuse a token whose application has since been
|
|
3
|
+
# deactivated or deleted (see StandardId::Oauth::RefreshTokenFlow
|
|
4
|
+
# #validate_client_application_liveness!). Set at mint from the grant's
|
|
5
|
+
# resolved client (authorization_code, client_credentials, any grant whose
|
|
6
|
+
# client_id names a registered application); copied to each rotated successor;
|
|
7
|
+
# NULL where the client_id is not an application (social sign-in refreshes as
|
|
8
|
+
# the provider's name) and on rows minted before this ran, which are bound on
|
|
9
|
+
# their next rotation.
|
|
10
|
+
#
|
|
11
|
+
# Deliberately NO foreign key: a deleted application must stay detectable as
|
|
12
|
+
# "gone" from the token's side, which a cascading or nullifying FK would erase.
|
|
13
|
+
# One nullable column, no default, no index, no backfill: metadata-only on
|
|
14
|
+
# PostgreSQL, so safe on a large table and under StrongMigrations. Until the
|
|
15
|
+
# migration has run the gem does not write or read the column; a deactivated
|
|
16
|
+
# application is still refused by client_id lookup, a deleted one is not.
|
|
17
|
+
class AddClientApplicationIdToStandardIdRefreshTokens < ActiveRecord::Migration[8.0]
|
|
18
|
+
include StandardId::MigrationHelpers
|
|
19
|
+
|
|
20
|
+
def change
|
|
21
|
+
add_column :standard_id_refresh_tokens, :client_application_id, foreign_key_type
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -13,6 +13,10 @@ StandardId.configure do |c|
|
|
|
13
13
|
# include StandardId::AccountAssociations (see post-install checklist).
|
|
14
14
|
# Default: "User"
|
|
15
15
|
c.account_class_name = "User"
|
|
16
|
+
# The enum key StandardId::AccountStatus uses for a deactivated account
|
|
17
|
+
# (default :inactive). Set it to the value your status column already
|
|
18
|
+
# carries, e.g. :suspended, to adopt the concern without renaming it.
|
|
19
|
+
# c.inactive_account_status = :inactive
|
|
16
20
|
|
|
17
21
|
# JWT "iss" claim — typically your application's canonical base URL. When
|
|
18
22
|
# set, tokens embed it and verification enforces it. Strongly recommended
|
|
@@ -12,8 +12,26 @@ module StandardId
|
|
|
12
12
|
attr_accessor :subscribed
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# The configured enum key for a deactivated account (`config.inactive_account_status`).
|
|
16
|
+
# `:active` is refused: the enum would collapse to one key and every
|
|
17
|
+
# account would read as deactivated.
|
|
18
|
+
def self.inactive_status
|
|
19
|
+
status = StandardId.config.inactive_account_status.to_s.presence&.to_sym || :inactive
|
|
20
|
+
raise StandardId::ConfigurationError, "inactive_account_status cannot be :active" if status == :active
|
|
21
|
+
|
|
22
|
+
status
|
|
23
|
+
end
|
|
24
|
+
|
|
15
25
|
included do
|
|
16
|
-
|
|
26
|
+
# The deactivated key is read from config ONCE, here, when the concern is
|
|
27
|
+
# included: a host whose column already says "suspended" declares
|
|
28
|
+
# `c.inactive_account_status = :suspended` and gets `suspended?`, the
|
|
29
|
+
# `suspended` scope and status "suspended", with no enum value to rename.
|
|
30
|
+
inactive_status = StandardId::AccountStatus.inactive_status
|
|
31
|
+
class_attribute :standard_id_inactive_status, instance_writer: false, default: inactive_status
|
|
32
|
+
|
|
33
|
+
enum :status, { active: "active", inactive_status => inactive_status.to_s }, default: :active
|
|
34
|
+
alias_method :inactive?, :"#{inactive_status}?" unless inactive_status == :inactive
|
|
17
35
|
|
|
18
36
|
after_commit :emit_account_status_changed_event, on: :update, if: :status_previously_changed?
|
|
19
37
|
|
|
@@ -26,29 +44,52 @@ module StandardId
|
|
|
26
44
|
StandardId::Events::SESSION_VALIDATING
|
|
27
45
|
) do |event|
|
|
28
46
|
account = event[:account]
|
|
29
|
-
if account
|
|
30
|
-
raise StandardId::AccountDeactivatedError,
|
|
47
|
+
if StandardId::AccountStatus.deactivated?(account)
|
|
48
|
+
raise StandardId::AccountDeactivatedError, StandardId::AccountStatus.deactivated_message(account)
|
|
31
49
|
end
|
|
32
50
|
end
|
|
33
51
|
end
|
|
34
52
|
end
|
|
35
53
|
|
|
54
|
+
# Whether +account+ is in its configured deactivated status. False for nil
|
|
55
|
+
# and for a model that does not include this concern.
|
|
56
|
+
def self.deactivated?(account)
|
|
57
|
+
return false unless account.respond_to?(:standard_id_inactive_status)
|
|
58
|
+
|
|
59
|
+
account.public_send(:"#{account.standard_id_inactive_status}?")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# "Account is deactivated" for the default key; "Account is suspended" (the
|
|
63
|
+
# host's own word) for a renamed one.
|
|
64
|
+
def self.deactivated_message(account)
|
|
65
|
+
status = account.standard_id_inactive_status
|
|
66
|
+
status == :inactive ? "Account is deactivated" : "Account is #{status}"
|
|
67
|
+
end
|
|
68
|
+
|
|
36
69
|
def activate!
|
|
37
70
|
return true if active?
|
|
38
71
|
|
|
39
|
-
|
|
72
|
+
attributes = { status: :active }
|
|
73
|
+
attributes[:activated_at] = Time.current if has_attribute?(:activated_at)
|
|
74
|
+
update!(**attributes)
|
|
40
75
|
end
|
|
41
76
|
|
|
42
77
|
def deactivate!
|
|
43
|
-
return true if
|
|
78
|
+
return true if standard_id_deactivated?
|
|
44
79
|
|
|
45
|
-
|
|
80
|
+
attributes = { status: standard_id_inactive_status }
|
|
81
|
+
attributes[:deactivated_at] = Time.current if has_attribute?(:deactivated_at)
|
|
82
|
+
update!(**attributes)
|
|
46
83
|
end
|
|
47
84
|
|
|
48
85
|
private
|
|
49
86
|
|
|
87
|
+
def standard_id_deactivated?
|
|
88
|
+
StandardId::AccountStatus.deactivated?(self)
|
|
89
|
+
end
|
|
90
|
+
|
|
50
91
|
def emit_account_status_changed_event
|
|
51
|
-
event =
|
|
92
|
+
event = standard_id_deactivated? ? StandardId::Events::ACCOUNT_DEACTIVATED : StandardId::Events::ACCOUNT_ACTIVATED
|
|
52
93
|
StandardId::Events.publish(
|
|
53
94
|
event,
|
|
54
95
|
account: self,
|
|
@@ -43,6 +43,17 @@ StandardId::ConfigSchema.define do
|
|
|
43
43
|
field :inertia_component_namespace, type: :string, default: "standard_id"
|
|
44
44
|
field :alias_current_user, type: :boolean, default: false
|
|
45
45
|
|
|
46
|
+
# The enum key `StandardId::AccountStatus` declares for a deactivated
|
|
47
|
+
# account, and so its stored value and predicate: `:inactive` (default)
|
|
48
|
+
# gives `inactive?` / status "inactive"; `:suspended` gives `suspended?` /
|
|
49
|
+
# status "suspended". For a host whose status column already carries a
|
|
50
|
+
# different value and whose code reads that predicate everywhere, so it can
|
|
51
|
+
# adopt the concern (and the sign-in / token / live-session guard it
|
|
52
|
+
# installs) without renaming a database enum value under running
|
|
53
|
+
# processes. `inactive?` stays available as an alias. Read when the concern
|
|
54
|
+
# is included, so set it in the initializer, before the account model loads.
|
|
55
|
+
field :inactive_account_status, type: :symbol, default: :inactive
|
|
56
|
+
|
|
46
57
|
# Opt the gem's own associations out of an app-wide
|
|
47
58
|
# `strict_loading_by_default = true`.
|
|
48
59
|
#
|
|
@@ -457,6 +468,24 @@ StandardId::ConfigSchema.define do
|
|
|
457
468
|
# immediately.
|
|
458
469
|
field :introspection_enabled, type: :boolean, default: false
|
|
459
470
|
|
|
471
|
+
# A host-side liveness check for `/oauth/introspect`, run after the gem's
|
|
472
|
+
# own checks pass (signature, expiry, refresh-token row, parent session,
|
|
473
|
+
# bound ClientApplication) and before the active response is rendered. A
|
|
474
|
+
# callable taking any subset of `payload:` (the decoded claims, string or
|
|
475
|
+
# symbol keys), `refresh_token:` (the persisted StandardId::RefreshToken,
|
|
476
|
+
# nil for an access token) and `request:`. A falsy return renders
|
|
477
|
+
# `{"active": false}`, nothing else. For claims the host mints that point
|
|
478
|
+
# at its own rows — a profile `gid`, a tenant — whose liveness only the
|
|
479
|
+
# host can judge. An exception is logged and counts as inactive: a check
|
|
480
|
+
# that cannot answer must not say yes.
|
|
481
|
+
field :introspection_liveness, type: :any, default: nil
|
|
482
|
+
|
|
483
|
+
# Extra claim names copied from the token into an ACTIVE introspection
|
|
484
|
+
# response, beyond the RFC 7662 members the gem always returns — e.g.
|
|
485
|
+
# `%w[profile_type gid]`. A claim the token lacks is omitted; the standard
|
|
486
|
+
# members (`active`, `sub`, `aud`, …) cannot be overridden.
|
|
487
|
+
field :introspection_claims, type: :array, default: -> { [] }
|
|
488
|
+
|
|
460
489
|
# Grant types the token endpoint refuses even though the engine implements
|
|
461
490
|
# them. Values are the `grant_type` strings: "authorization_code",
|
|
462
491
|
# "refresh_token", "client_credentials", "password", "passwordless_otp".
|
|
@@ -670,6 +699,26 @@ StandardId::ConfigSchema.define do
|
|
|
670
699
|
field :api_passwordless_start_per_ip, type: :integer, default: 10 # per hour
|
|
671
700
|
field :api_passwordless_start_per_target, type: :integer, default: 5 # per 15 minutes
|
|
672
701
|
|
|
702
|
+
# OTP start limits enforced INSIDE the passwordless strategy
|
|
703
|
+
# (Passwordless::BaseStrategy#start!), not on a controller — so they hold
|
|
704
|
+
# for a host that drives `generate_passwordless_otp`, `StandardId::Otp.issue`
|
|
705
|
+
# or the strategy from its own controllers, operations or jobs, where the
|
|
706
|
+
# controller-level limits above are never consulted. Counted on the same
|
|
707
|
+
# store, keyed per (realm, channel, target) and per IP, raising
|
|
708
|
+
# StandardId::RateLimitExceededError (a 429 through the API error handler,
|
|
709
|
+
# a flash on the web login page, a failed result from Otp.issue).
|
|
710
|
+
#
|
|
711
|
+
# The per-target limit defaults to the API's per-target value, so a path
|
|
712
|
+
# that already runs the controller limit sees no change (the controller
|
|
713
|
+
# trips first, with its Retry-After). A host that RAISES a controller
|
|
714
|
+
# per-target limit (api_passwordless_start_per_target, login_per_email)
|
|
715
|
+
# must raise this one too, or it becomes the binding limit. The per-IP
|
|
716
|
+
# limit defaults OFF: the
|
|
717
|
+
# right ceiling depends on how many real users share one address (CGNAT,
|
|
718
|
+
# an office), and a wrong default would bounce them. 0 disables either.
|
|
719
|
+
field :otp_start_per_target, type: :integer, default: 5 # per 15 minutes
|
|
720
|
+
field :otp_start_per_ip, type: :integer, default: 0 # per hour; 0 = off
|
|
721
|
+
|
|
673
722
|
# OAuth token endpoint — requests per IP.
|
|
674
723
|
field :api_token_per_ip, type: :integer, default: 30 # per 15 minutes
|
|
675
724
|
|
data/lib/standard_id/errors.rb
CHANGED
|
@@ -74,6 +74,40 @@ module StandardId
|
|
|
74
74
|
def oauth_error_code = :invalid_request
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# A lib-level rate limit tripped (StandardId::RateLimiter). A subclass of
|
|
78
|
+
# InvalidRequestError so every path that already turns a refused OTP start
|
|
79
|
+
# into a flash, a failure result or an OAuth error body keeps doing so; the
|
|
80
|
+
# API error handler renders it as 429 `rate_limit_exceeded`, the same shape
|
|
81
|
+
# the controller-level limits produce.
|
|
82
|
+
class RateLimitExceededError < InvalidRequestError
|
|
83
|
+
# Length in seconds of the window that tripped (an upper bound on the
|
|
84
|
+
# wait, not the time remaining), for a Retry-After header.
|
|
85
|
+
attr_reader :retry_after
|
|
86
|
+
|
|
87
|
+
def initialize(message = "Too many requests. Please try again later.", retry_after: nil)
|
|
88
|
+
super(message)
|
|
89
|
+
@retry_after = retry_after
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def oauth_error_code = :rate_limit_exceeded
|
|
93
|
+
def http_status = :too_many_requests
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The OTP resend cooldown (`passwordless.retry_delay`) refused a start: a code
|
|
97
|
+
# for this target was issued less than retry_delay seconds ago and is still
|
|
98
|
+
# usable, so nothing new was sent. A subclass of InvalidRequestError so every
|
|
99
|
+
# existing rescue keeps working; Otp.issue reports it as `error_code:
|
|
100
|
+
# :cooldown` so a host can show its own "a code is already on its way" copy
|
|
101
|
+
# and tell it apart from a send failure. `retry_after` is the seconds left.
|
|
102
|
+
class RetryDelayError < InvalidRequestError
|
|
103
|
+
attr_reader :retry_after
|
|
104
|
+
|
|
105
|
+
def initialize(message = "Please wait before requesting another code", retry_after: nil)
|
|
106
|
+
super(message)
|
|
107
|
+
@retry_after = retry_after
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
77
111
|
class InvalidClientError < OAuthError
|
|
78
112
|
def oauth_error_code = :invalid_client
|
|
79
113
|
def http_status = :unauthorized
|
|
@@ -42,6 +42,7 @@ module StandardId
|
|
|
42
42
|
|
|
43
43
|
validate_refresh_token_record!
|
|
44
44
|
validate_scope_narrowing!
|
|
45
|
+
validate_client_application_liveness!
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
private
|
|
@@ -77,6 +78,66 @@ module StandardId
|
|
|
77
78
|
raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
|
|
78
79
|
end
|
|
79
80
|
|
|
81
|
+
# Refuse a refresh whose ClientApplication has been deactivated or deleted.
|
|
82
|
+
#
|
|
83
|
+
# Deleting a client is the only revocation RFC 7592 gives a client manager,
|
|
84
|
+
# and a public (DCR, PKCE) client never sends a secret, so until this check
|
|
85
|
+
# existed the grant never looked the client up at all: DELETE
|
|
86
|
+
# /oauth/register/:client_id answered 204 while every refresh token it had
|
|
87
|
+
# issued kept rotating. Hosts closed that with a prepend on this class.
|
|
88
|
+
#
|
|
89
|
+
# The binding is the row's `client_application_id` (migration
|
|
90
|
+
# 20261020000000), copied down each rotation. A row without one — minted
|
|
91
|
+
# before the column existed, or on a host that has not migrated yet — is
|
|
92
|
+
# checked by the token's client_id instead: refused if an application with
|
|
93
|
+
# that client_id exists and is inactive, and (once the column exists) its
|
|
94
|
+
# successor carries the id. So deactivation bites with or without the
|
|
95
|
+
# migration; only deletion needs the column, because a deleted
|
|
96
|
+
# application leaves nothing to look up by client_id. A token whose
|
|
97
|
+
# client_id is not an application at all (social sign-in refreshes as the
|
|
98
|
+
# provider's name; a free-form client_id that happens to collide with a
|
|
99
|
+
# registered one is treated as that application) is left alone.
|
|
100
|
+
#
|
|
101
|
+
# Deactivation suspends: nothing is revoked, so reactivating the
|
|
102
|
+
# application restores its tokens. One exception, deliberate: reuse
|
|
103
|
+
# detection in #validate_refresh_token_record! runs first, so a replayed
|
|
104
|
+
# already-rotated token still revokes its family while the application is
|
|
105
|
+
# suspended. That is the theft signal it always was, and suspension is
|
|
106
|
+
# not a reason to stop honouring it. Deletion is final because the row is
|
|
107
|
+
# gone and client_ids are random. Either way the answer is the generic
|
|
108
|
+
# `invalid_grant` every other dead token gets (RFC 6749 §5.2): a distinct
|
|
109
|
+
# message would tell whoever holds a leaked token why it stopped working.
|
|
110
|
+
def validate_client_application_liveness!
|
|
111
|
+
return if @current_refresh_token_record.nil?
|
|
112
|
+
return if client_application_live?
|
|
113
|
+
|
|
114
|
+
raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def client_application_live?
|
|
118
|
+
record = @current_refresh_token_record
|
|
119
|
+
return record.client_application_live? if record.try(:client_application_id).present?
|
|
120
|
+
|
|
121
|
+
client = token_client
|
|
122
|
+
client.nil? || client.active?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Resolved by client_id (TokenGrantFlow#token_client), memoised: the
|
|
126
|
+
# liveness check, the successor's binding and the claim resolvers all
|
|
127
|
+
# read it on the hot path.
|
|
128
|
+
def token_client
|
|
129
|
+
return @token_client if defined?(@token_client)
|
|
130
|
+
|
|
131
|
+
@token_client = super
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The successor inherits the presented token's binding; a legacy unbound
|
|
135
|
+
# token's successor is bound by client_id (see
|
|
136
|
+
# #validate_client_application_liveness!).
|
|
137
|
+
def bound_client_application_id
|
|
138
|
+
@current_refresh_token_record.try(:client_application_id) || super
|
|
139
|
+
end
|
|
140
|
+
|
|
80
141
|
def validate_refresh_token_record!
|
|
81
142
|
jti = @refresh_payload[:jti]
|
|
82
143
|
# Legacy tokens minted before jti tracking was added cannot be looked
|
|
@@ -230,10 +230,27 @@ module StandardId
|
|
|
230
230
|
token_digest: StandardId::RefreshToken.digest_for(jti),
|
|
231
231
|
expires_at: expires_at,
|
|
232
232
|
previous_token: previous_refresh_token_record,
|
|
233
|
-
**StandardId::AuthLineage.refresh_token_attributes(refresh_token_auth_lineage)
|
|
233
|
+
**StandardId::AuthLineage.refresh_token_attributes(refresh_token_auth_lineage),
|
|
234
|
+
**client_binding_attributes
|
|
234
235
|
)
|
|
235
236
|
end
|
|
236
237
|
|
|
238
|
+
# The ClientApplication the refresh token is bound to, so the
|
|
239
|
+
# refresh_token grant can refuse it once that application is deactivated
|
|
240
|
+
# or deleted. Nothing is written until migration 20261020000000 has run.
|
|
241
|
+
def client_binding_attributes
|
|
242
|
+
return {} unless StandardId::RefreshToken.client_binding_column?
|
|
243
|
+
|
|
244
|
+
{ client_application_id: bound_client_application_id }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# nil when the grant's client_id is not a registered application (the
|
|
248
|
+
# social flow, a password/passwordless grant with a free-form client_id):
|
|
249
|
+
# such a token has no application whose fate to share.
|
|
250
|
+
def bound_client_application_id
|
|
251
|
+
token_client&.id
|
|
252
|
+
end
|
|
253
|
+
|
|
237
254
|
# How the authentication behind this grant was established, recorded on
|
|
238
255
|
# the refresh token so the refresh_token grant can re-check the
|
|
239
256
|
# login-method policy against it. Grants that authenticate an account
|
data/lib/standard_id/otp.rb
CHANGED
|
@@ -74,7 +74,10 @@ module StandardId
|
|
|
74
74
|
# - success?: true/false
|
|
75
75
|
# - challenge: the created CodeChallenge (nil on failure)
|
|
76
76
|
# - code: raw OTP code — only populated when delivery: :manual
|
|
77
|
-
# - error_code: machine-readable symbol on failure
|
|
77
|
+
# - error_code: machine-readable symbol on failure — :invalid_request
|
|
78
|
+
# (bad target, username_validator rejection), :cooldown (a usable code
|
|
79
|
+
# was issued less than passwordless.retry_delay seconds ago; nothing new
|
|
80
|
+
# was sent), :rate_limited (rate_limits.otp_start_per_* tripped)
|
|
78
81
|
# - error_message: human-readable message on failure
|
|
79
82
|
IssueResult = Data.define(:success?, :challenge, :code, :error_code, :error_message) do
|
|
80
83
|
# Parallel naming with VerificationService::Result, which exposes the
|
|
@@ -132,6 +135,15 @@ module StandardId
|
|
|
132
135
|
skip_sender: delivery_sym == :manual,
|
|
133
136
|
delivery: delivery_sym
|
|
134
137
|
)
|
|
138
|
+
rescue StandardId::RetryDelayError => e
|
|
139
|
+
# The resend cooldown (passwordless.retry_delay): a usable code was
|
|
140
|
+
# issued moments ago and nothing new was sent. Distinct code so a
|
|
141
|
+
# caller can say "it's on its way" rather than report a failure.
|
|
142
|
+
return failure_issue_result(:cooldown, e.message)
|
|
143
|
+
rescue StandardId::RateLimitExceededError => e
|
|
144
|
+
# A lib-level start limit (rate_limits.otp_start_per_*) tripped.
|
|
145
|
+
# Distinct code so a caller can answer 429 rather than 400.
|
|
146
|
+
return failure_issue_result(:rate_limited, e.message)
|
|
135
147
|
rescue StandardId::InvalidRequestError => e
|
|
136
148
|
# Validation failures from the strategy (invalid email/phone format,
|
|
137
149
|
# custom username_validator rejections, etc.) are surfaced as a
|
|
@@ -32,14 +32,26 @@ module StandardId
|
|
|
32
32
|
|
|
33
33
|
validate_username!(username)
|
|
34
34
|
run_username_validator!(username)
|
|
35
|
-
|
|
35
|
+
enforce_start_rate_limits!(username)
|
|
36
|
+
# Cooldown check and challenge creation in ONE transaction, serialised
|
|
37
|
+
# per target (see #lock_target!), so two concurrent starts for the same
|
|
38
|
+
# address cannot both pass the check and both issue a code — which
|
|
39
|
+
# invalidated the first code the moment the second was created and
|
|
40
|
+
# left the user holding a dead one (fundbright delivery-ops#345).
|
|
41
|
+
challenge = ActiveRecord::Base.transaction do
|
|
42
|
+
lock_target!(username)
|
|
43
|
+
enforce_retry_delay!(username)
|
|
44
|
+
create_challenge!(
|
|
45
|
+
username,
|
|
46
|
+
code_length: code_length,
|
|
47
|
+
expires_in: expires_in,
|
|
48
|
+
metadata: metadata
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
# Published once the row is committed: a subscriber that writes must
|
|
52
|
+
# not have its work rolled back with a failed insert, nor hold the
|
|
53
|
+
# per-target lock for its whole runtime. Still before CODE_GENERATED.
|
|
36
54
|
emit_code_requested(username)
|
|
37
|
-
challenge = create_challenge!(
|
|
38
|
-
username,
|
|
39
|
-
code_length: code_length,
|
|
40
|
-
expires_in: expires_in,
|
|
41
|
-
metadata: metadata
|
|
42
|
-
)
|
|
43
55
|
# skip_sender is forwarded into the event payload so subscribers that
|
|
44
56
|
# deliver on PASSWORDLESS_CODE_GENERATED (e.g. PasswordlessDeliverySubscriber)
|
|
45
57
|
# can honor a per-call manual-delivery request. Without this,
|
|
@@ -52,6 +64,44 @@ module StandardId
|
|
|
52
64
|
|
|
53
65
|
protected
|
|
54
66
|
|
|
67
|
+
# Per-target and per-IP ceilings on starts (rate_limits.otp_start_per_*),
|
|
68
|
+
# counted here rather than on a controller so they hold whichever way a
|
|
69
|
+
# host reaches the strategy. The target key carries the realm and
|
|
70
|
+
# channel, so an authentication code and a verification code for the
|
|
71
|
+
# same address are separate budgets, as they are separate challenges.
|
|
72
|
+
def enforce_start_rate_limits!(username)
|
|
73
|
+
limits = StandardId.config.rate_limits
|
|
74
|
+
target = username.to_s.strip.downcase
|
|
75
|
+
StandardId::RateLimiter.hit!(
|
|
76
|
+
"standard_id:otp-start:target:#{@realm}:#{connection_type}:#{target}",
|
|
77
|
+
limit: limits.otp_start_per_target, window: 15.minutes
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
ip = request.respond_to?(:remote_ip) ? request.remote_ip : nil
|
|
81
|
+
return if ip.blank?
|
|
82
|
+
|
|
83
|
+
StandardId::RateLimiter.hit!(
|
|
84
|
+
"standard_id:otp-start:ip:#{StandardId::Utils::IpNormalizer.normalize(ip)}",
|
|
85
|
+
limit: limits.otp_start_per_ip, window: 1.hour
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Serialise concurrent starts for one (realm, channel, target) for the
|
|
90
|
+
# rest of the enclosing transaction. On PostgreSQL this is a transaction-
|
|
91
|
+
# scoped advisory lock, released at commit or rollback, so a second start
|
|
92
|
+
# waits for the first to finish and then sees the challenge it created.
|
|
93
|
+
# Other adapters get no cross-process lock: the retry-delay check there
|
|
94
|
+
# stays what it was, a read followed by a write. Keyed on the normalised
|
|
95
|
+
# target (as the rate limit is), so "User@x" and "user@x" contend for one
|
|
96
|
+
# lock even when a caller skipped the web login's own downcasing.
|
|
97
|
+
def lock_target!(username)
|
|
98
|
+
connection = StandardId::CodeChallenge.connection
|
|
99
|
+
return unless connection.adapter_name.match?(/postg/i)
|
|
100
|
+
|
|
101
|
+
key = "standard_id:otp:#{@realm}:#{connection_type}:#{username.to_s.strip.downcase}"
|
|
102
|
+
connection.execute("SELECT pg_advisory_xact_lock(hashtext(#{connection.quote(key)}))")
|
|
103
|
+
end
|
|
104
|
+
|
|
55
105
|
# Reject a resend that arrives within passwordless.retry_delay seconds of
|
|
56
106
|
# the most recent still-active code for this target. A soft cooldown that
|
|
57
107
|
# complements the coarser per-target rate limits: it does not consume the
|
|
@@ -71,8 +121,10 @@ module StandardId
|
|
|
71
121
|
return if elapsed >= delay
|
|
72
122
|
|
|
73
123
|
wait = (delay - elapsed).ceil
|
|
74
|
-
raise StandardId::
|
|
75
|
-
"Please wait #{wait} #{'second'.pluralize(wait)} before requesting another code"
|
|
124
|
+
raise StandardId::RetryDelayError.new(
|
|
125
|
+
"Please wait #{wait} #{'second'.pluralize(wait)} before requesting another code",
|
|
126
|
+
retry_after: wait
|
|
127
|
+
)
|
|
76
128
|
end
|
|
77
129
|
|
|
78
130
|
def create_challenge!(username, code_length: nil, expires_in: nil, metadata: {})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# Fixed-window counters on the configured rate-limit store, for limits the
|
|
3
|
+
# engine enforces OUTSIDE a controller — where Rails' `rate_limit` macro
|
|
4
|
+
# cannot reach. The first one is the OTP start path (Passwordless::BaseStrategy
|
|
5
|
+
# #start!), which a host can drive from its own controllers, an operation or
|
|
6
|
+
# a job without ever passing the engine's throttled actions.
|
|
7
|
+
#
|
|
8
|
+
# `STORE` is the same lazily-resolved RateLimitStore the engine's controllers
|
|
9
|
+
# count on (RateLimitHandling::RATE_LIMIT_STORE is this object), so one
|
|
10
|
+
# `c.cache_store` governs both, and a spec that stubs one stubs the other.
|
|
11
|
+
module RateLimiter
|
|
12
|
+
STORE = StandardId::RateLimitStore.new
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Count one hit against +key+ and raise once the window holds more than
|
|
17
|
+
# +limit+. A non-positive limit disables the check. A store that cannot
|
|
18
|
+
# count (`ActiveSupport::Cache::NullStore` answers nil) disables it too:
|
|
19
|
+
# there is nothing to count against, and refusing everyone is worse than
|
|
20
|
+
# counting nothing.
|
|
21
|
+
#
|
|
22
|
+
# @param key [String]
|
|
23
|
+
# @param limit [Integer]
|
|
24
|
+
# @param window [ActiveSupport::Duration, Integer] seconds
|
|
25
|
+
# @raise [StandardId::RateLimitExceededError]
|
|
26
|
+
def hit!(key, limit:, window:)
|
|
27
|
+
return if limit.to_i <= 0
|
|
28
|
+
|
|
29
|
+
count = STORE.increment(key, 1, expires_in: window)
|
|
30
|
+
return if count.nil? || count.to_i <= limit.to_i
|
|
31
|
+
|
|
32
|
+
raise StandardId::RateLimitExceededError.new(retry_after: window.to_i)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/standard_id/version.rb
CHANGED
data/lib/standard_id.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.48.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -238,6 +238,7 @@ files:
|
|
|
238
238
|
- db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb
|
|
239
239
|
- db/migrate/20261002000000_create_standard_id_social_identities.rb
|
|
240
240
|
- db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb
|
|
241
|
+
- db/migrate/20261020000000_add_client_application_id_to_standard_id_refresh_tokens.rb
|
|
241
242
|
- lib/generators/standard_id/install/install_generator.rb
|
|
242
243
|
- lib/generators/standard_id/install/templates/standard_id.rb
|
|
243
244
|
- lib/standard_id.rb
|
|
@@ -309,6 +310,7 @@ files:
|
|
|
309
310
|
- lib/standard_id/providers/base.rb
|
|
310
311
|
- lib/standard_id/providers/plugin_railtie.rb
|
|
311
312
|
- lib/standard_id/rate_limit_store.rb
|
|
313
|
+
- lib/standard_id/rate_limiter.rb
|
|
312
314
|
- lib/standard_id/routing.rb
|
|
313
315
|
- lib/standard_id/scope_config.rb
|
|
314
316
|
- lib/standard_id/session_type_resolver.rb
|