standard_id 0.32.0 → 0.33.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: 9186b11b608c1e58f0ea0a2c957eac6e88aaeb7ea18012fe133b40c5d77c54e9
4
- data.tar.gz: 888389931fa6d8854c4632f175db13409c32991905870cdcbb2c16bc53c912cb
3
+ metadata.gz: c5341add412ad2ef395a4252d31f751e5b9eeb9df585f1eb3e5c276d7071211f
4
+ data.tar.gz: b53b6dc29f5aa4a5994b040f5a6cdca2441c4c0c57cb2449b1fc1aead9caf4a7
5
5
  SHA512:
6
- metadata.gz: 423dc3b53caf3fad0e0ba2ea41230627a07328e27fdb29439bfaaafb0701b1887f78744321c2579ac8018741a96fda684230d488604702b865e5b8193ebecce4
7
- data.tar.gz: c16d76284752125e7191ca9ae7770a9fed1c3f0af6078f0221bfb294eaddf78f16701c1954147467f9118916d9d662d50dae5b6844589ffc76d65b14af406ab2
6
+ metadata.gz: a4162b488000bc8e889dba2887ffe2257ad8ac342d7612aff936c74fccbf30f22560f4194c39e0552446e3e6a93b2d335c2d34b3d27ceb67586fdeb188de5181
7
+ data.tar.gz: fd04fc771429eaf0ed9efd4fa01fa85263983be3dd8cdf9a5b3df16194959387d929aa7bcf27711681dfb0262c3d57fd1df11f64761128646f43d9bb495ad8da
data/CHANGELOG.md CHANGED
@@ -7,6 +7,287 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.33.0] - 2026-07-30
11
+
12
+ ### Added
13
+
14
+ - **RFC 7662 token introspection** — `POST /oauth/introspect`, behind
15
+ `config.oauth.introspection_enabled` (default **false**). When off the
16
+ endpoint returns 404 and `introspection_endpoint` is not advertised in either
17
+ discovery document, mirroring how `dynamic_registration_enabled` gates
18
+ `/oauth/register`.
19
+
20
+ Confidential clients only: `client_id` + `client_secret`, via HTTP Basic or
21
+ the form body (RFC 6749 §2.3.1 — never both). Every failure mode renders
22
+ `{"active": false}` with **no other members**, per RFC 7662 §2.2.
23
+
24
+ Throttled per IP via `config.rate_limits.introspection_per_ip` (default 30 per
25
+ 15 minutes).
26
+
27
+ Two properties are load-bearing and deliberately not what you would reach for:
28
+
29
+ - **The rate limit renders `{"active": false}` / 200, never 429.** A 429
30
+ distinguishes "you are throttled" from "that token is not valid", which turns
31
+ the limiter into a token-validity oracle — an attacker probes until throttled
32
+ and then reads the *status code* to classify tokens. This opts out of
33
+ `RateLimitHandling.rate_limit`'s wrapper by passing an explicit `with:`. The
34
+ reason is commented at the call site, because it reads like a bug otherwise.
35
+ - **The limit keys on `request.remote_ip`** — not Rack's `request.ip`, which
36
+ resolves the forwarding chain against Rack's own trusted-proxy list rather
37
+ than `config.action_dispatch.trusted_proxies` and therefore collapses every
38
+ caller behind a CDN into one bucket. And never on the `Authorization` header
39
+ or `client_id`: `rate_limit` is a `before_action` that runs *before* client
40
+ authentication, so a header-derived key is attacker-controlled and a caller
41
+ could mint a fresh bucket per request by rotating it.
42
+
43
+ **Know this limit before building an authorization gate on it.** Access tokens
44
+ are stateless — never persisted, carrying no `sid` — so **a revoked session's
45
+ access token introspects as `active: true` until its `exp`**. Introspection
46
+ answers "did we mint this, and is it unexpired?", not "is it still honoured?".
47
+ The only mitigation is a short access-token lifetime. Refresh tokens *are*
48
+ persisted (as `SHA256(jti)`) and are checked against the row, so a revoked or
49
+ expired refresh token introspects as inactive immediately, and is reported with
50
+ `token_type: "refresh_token"`. Both halves are asserted by specs, the access-token
51
+ one deliberately, so nobody later "corrects" the documentation to overclaim.
52
+
53
+ - `config.oauth.introspection_enabled` and
54
+ `config.rate_limits.introspection_per_ip`, both documented inline in the
55
+ install template.
56
+
57
+
58
+ - `StandardId::ProviderRegistry.declare_config_schemas!`,
59
+ `.declare_config_schema(provider_class)` and `.provider_classes` — public
60
+ entry points for the above. Useful if you define a provider somewhere the
61
+ boot-time sweep cannot see it (e.g. under `app/`, autoloaded) and need to
62
+ declare its fields yourself.
63
+
64
+ - **`config.association_strict_loading`** — a supported way to opt the gem's own
65
+ associations out of an app-wide `strict_loading_by_default = true`.
66
+
67
+ Two consuming apps were reaching into Rails internals to do this:
68
+
69
+ ```ruby
70
+ Account.reflect_on_association(assoc)&.options&.[]=(:strict_loading, false)
71
+ ```
72
+
73
+ both with a comment asking for exactly this hook. They could not simply
74
+ re-declare the associations: they are declared inside
75
+ `StandardId::AccountAssociations`, and `credentials` is a `has_many :through`,
76
+ where re-declaration risks ordering breakage. Passing `strict_loading:` at
77
+ declaration time is the supported Rails API, and unlike re-declaration it
78
+ works for the `:through` association.
79
+
80
+ Covers `Account#identifiers`, `#credentials`, `#sessions`, `#refresh_tokens`,
81
+ `#client_applications`, plus `Session#refresh_tokens` and
82
+ `Identifier#credentials`.
83
+
84
+ **Tri-state, and `nil` is not `false`:**
85
+
86
+ | value | effect |
87
+ |---|---|
88
+ | `nil` (default) | no `strict_loading:` option is declared at all — the associations inherit the owner's setting, exactly as before |
89
+ | `false` | opt out; may lazy-load even with strict loading on app-wide |
90
+ | `true` | opt in; strict loading enforced even if off app-wide |
91
+
92
+ That distinction is load-bearing rather than stylistic. Rails checks
93
+ `reflection.options.key?(:strict_loading)` *before* consulting the owner
94
+ (`Association#violates_strict_loading?`), so declaring `strict_loading: nil`
95
+ would put the key in the options hash and make `reflection.strict_loading?`
96
+ return `false` — silently disabling strict loading on every gem association in
97
+ every app that never asked for it. The option is therefore omitted entirely
98
+ when unconfigured, and a spec asserts the key's absence.
99
+
100
+ **Set it in `config/initializers/standard_id.rb`, not in an
101
+ `after_initialize` block.** The associations read it when your `Account` class
102
+ body runs. StandardId raises a `ConfigurationError` at boot naming the ordering
103
+ problem if a declaration disagrees with the configured value — otherwise a
104
+ too-late assignment fails silently, surfacing as an
105
+ `ActiveRecord::StrictLoadingViolationError` deep inside a request or a quiet
106
+ N+1 in production.
107
+
108
+ - **`StandardId::Session.revoke_all_for!(account, reason:)`** — bulk session
109
+ revocation that cascades to refresh tokens, promoted from the private
110
+ `RevocationsController#revoke_sessions!` where it had lived since the RFC 7009
111
+ endpoint was built.
112
+
113
+ Consuming apps that bulk-revoked with a bare
114
+ `Session.where(account:).update_all(revoked_at:)` were silently skipping the
115
+ refresh-token cascade that `Session#revoke!` performs, leaving the holder of a
116
+ refresh token able to keep minting access tokens after a password reset or
117
+ account deactivation — and skipping the `session.revoked` event, so
118
+ audit-trail and account-locking subscribers never learned. This gives them a
119
+ supported API instead of a local re-implementation.
120
+
121
+ Two set-based UPDATEs (one per table) rather than `sessions.each(&:revoke!)`,
122
+ which is O(N) UPDATEs plus O(N) cascades — these call sites are admin bulk
123
+ actions and password resets.
124
+
125
+ ```ruby
126
+ result = StandardId::Session.revoke_all_for!(account, reason: "password_reset")
127
+ result.sessions_revoked # => 3
128
+ result.refresh_tokens_revoked # => 5
129
+ ```
130
+
131
+ Honours the current scope, so callers narrow as they need:
132
+ `StandardId::DeviceSession.active.revoke_all_for!(account, reason: "logout")`.
133
+ Accepts an account record or a bare id.
134
+
135
+ **Events: one `SESSION_REVOKED` per revoked session, never a single
136
+ aggregate** — subscribers must not need a second code path for bulk
137
+ revocation. Because `update_all` skips callbacks, each event is re-emitted
138
+ explicitly after the transaction commits, and each is individually rescued: a
139
+ raising subscriber must not short-circuit the loop and leave later sessions
140
+ without their event, which would permanently desync audit consumers from the
141
+ DB. Callers emit their own aggregate from the returned counts.
142
+
143
+ - **`StandardId::Session.revoke_sessions!(sessions, account:, reason:)`** — the
144
+ same set-based core over an explicit collection, for callers that have already
145
+ selected the sessions (e.g. the tokens of a single authorization grant).
146
+
147
+
148
+ - **`config.oauth.discovery_endpoint_base`** — where the advertised endpoints
149
+ actually live.
150
+
151
+ | value | meaning |
152
+ |---|---|
153
+ | `nil` (default) | the issuer — byte-identical to 0.32.0 |
154
+ | `:request` | `request.base_url` + the detected mount path |
155
+ | `"https://…"` | verbatim |
156
+ | `->(request:) { … }` | for a proxy that rewrites `base_url`, or an app mounting `ApiEngine` more than once (e.g. under host constraints) where no single detected path is right |
157
+
158
+ Request-derived is **opt-in, not the default**, on purpose: defaulting to it
159
+ would silently rewrite the document of every app whose issuer host differs from
160
+ the host serving the request — the split-host setup a separate issuer exists to
161
+ express.
162
+
163
+ Inside the mount the mount path needs no configuring or detecting: Rails sets
164
+ `SCRIPT_NAME` to the mount prefix, so `request.script_name` *is* the mount
165
+ path, exactly.
166
+
167
+ - **`standard_id_well_known_routes at: "<mount path>"`** — a routes-file mapper
168
+ helper that serves the documents at the **origin root**.
169
+
170
+ RFC 8615 clients probe the origin root, which is outside every engine mount, so
171
+ the gem could not draw those routes itself — this has to live in the host's
172
+ routes file.
173
+
174
+ For each metadata document it draws **both** the bare root form and the RFC
175
+ 8414 §3.1 **path-inserted** form:
176
+
177
+ ```
178
+ /.well-known/oauth-authorization-server
179
+ /.well-known/oauth-authorization-server/api/v1
180
+ ```
181
+
182
+ The second looks redundant and is not. §3.1 inserts the well-known segment
183
+ *before* a path-carrying issuer's path rather than appending to it, and that is
184
+ the URL Claude Code actually requests in production. Without it the client 404s,
185
+ falls back to issuer-relative defaults — hitting the engine's own `/authorize`
186
+ rather than a host audience shim — and every subsequent authenticated request
187
+ 401s, a long way from the cause. It is drawn for you rather than documented as
188
+ an extra step.
189
+
190
+ `only:` / `except:` select among `:oauth_authorization_server`,
191
+ `:openid_configuration`, `:jwks` — `only: :jwks` for an app that keeps its own
192
+ metadata controller but wants the gem's JWKS at the root. `extra_paths:` adds
193
+ further path-inserted suffixes. `path_inserted: false` draws only the bare root
194
+ forms. No path-inserted JWKS is drawn: it is a concrete file path, not a
195
+ document whose location §3.1 relocates.
196
+
197
+ - **`config.oauth.discovery_metadata_overrides`** — the members that cannot be
198
+ derived: an authorization endpoint pointing at a host-owned audience-injecting
199
+ shim (needed by all five apps, and the original reason they each wrote a
200
+ controller), a scope list deliberately narrower than what the server can mint,
201
+ an auth-method list that must mirror the host's dynamic-registration policy.
202
+
203
+ Values are static or callable; a callable receives
204
+ `{ origin:, endpoint_base:, issuer:, request: }` with `origin` carrying no
205
+ path. A **`nil` value removes the member** rather than emitting `null` — real
206
+ apps omit `scopes_supported` entirely, and a typed RFC 8414 member set to null
207
+ is worse than either alternative.
208
+
209
+ **Setting `issuer` raises `StandardId::ConfigurationError`**, naming
210
+ `discovery_endpoint_base` as the thing you probably wanted. It is refused
211
+ rather than ignored because an issuer diverging from what the token service
212
+ stamps yields a document that validates against nothing, failing far from its
213
+ cause.
214
+
215
+ See `docs/MIGRATION_GUIDE.md` for how to retire a hand-rolled controller.
216
+
217
+ ### Fixed
218
+
219
+ - **The discovery documents no longer ignore the `ApiEngine` mount prefix.**
220
+
221
+ `Oauth::DiscoveryDocument` derived every endpoint from the issuer
222
+ (`base = issuer.to_s.chomp("/")`). An app mounting `ApiEngine` under a prefix
223
+ its issuer does not carry — `/api`, `/api/v1` — therefore advertised
224
+ `<issuer>/oauth/token` while the endpoint actually lived at
225
+ `<origin>/api/oauth/token`. **Every advertised endpoint 404'd.** Both documents
226
+ were affected; the OIDC one had the identical bug.
227
+
228
+ All five consuming apps hand-rolled a replacement controller because of it.
229
+
230
+ `issuer` and the endpoint base are now separate values, and stay separate.
231
+ `issuer` is a stable security identifier (RFC 8414 §2) that clients match
232
+ byte-for-byte against both their discovery URL and the `iss` claim of issued
233
+ tokens: it is never derived from the request and cannot be overridden.
234
+
235
+
236
+ - `Session.revoke_sessions!` no longer aborts the revocation loop when logging
237
+ itself fails. The rescue around each `SESSION_REVOKED` publish exists so a
238
+ failing subscriber cannot short-circuit the loop and leave later sessions
239
+ without their event — but it logged via `StandardId.logger.error`, and
240
+ `StandardId.logger` is a *memoized* `config.logger || Rails.logger`, so
241
+ whatever the first reader in the process saw is what every later caller gets,
242
+ including a value that is not a logger at all. In that case the rescue raised
243
+ from inside itself and the guarantee evaporated. It now checks the logger
244
+ responds to `error` first.
245
+
246
+ - **Provider plugin config fields are now declared before host initializers
247
+ run**, so `config.social.google_client_id = ...` works in a plain
248
+ `config/initializers/standard_id.rb` — the way the install template, this
249
+ README, and both plugin READMEs all said it did.
250
+
251
+ It did not. `social.google_client_id` is not in the core schema; the plugin
252
+ declares it via `Providers::Google.config_schema`, which reached
253
+ `ConfigSchema.add_field` only through `ProviderRegistry.register`, called from
254
+ the plugin Railtie's `config.after_initialize` — long after
255
+ `:load_config_initializers`. The host's write therefore hit
256
+ `ConfigSchema::Scope#[]=` → `validate!` against a schema that did not yet know
257
+ the field and raised `StandardId::ConfigurationError: Unknown field
258
+ 'google_client_id' for scope 'social'`, with nothing in the message to suggest
259
+ the cause was boot ordering. Every consuming app that used a provider plugin
260
+ independently rediscovered the same
261
+ `Rails.application.config.after_initialize { ... }` workaround, and the three
262
+ documented forms disagreed with each other — two of them raised.
263
+
264
+ A new core Engine initializer, `standard_id.provider_config_schemas`, runs
265
+ `before: :load_config_initializers` and declares the fields of every loaded
266
+ provider class. **No plugin release is required**: provider classes are
267
+ required at gem-require time, so they are already loaded at that point.
268
+
269
+ Only *field declaration* moved earlier. Full `ProviderRegistry.register` —
270
+ which also runs `validate_provider!` and the provider's `setup` — stays in
271
+ `after_initialize`, where host configuration is complete.
272
+
273
+ **Existing `after_initialize` wrappers keep working verbatim.**
274
+ `add_field` is retroactive: `Scope#validate!` and `Scope#[]` both consult the
275
+ schema live, the latter falling back to the field's declared default for an
276
+ unwritten key, so declaring a field late is indistinguishable from declaring
277
+ it early. There is nothing to migrate.
278
+
279
+ The dummy app now loads a stand-in provider at application-require time and
280
+ writes its fields from an ordinary initializer, so a regression stops the app
281
+ booting rather than failing one assertion.
282
+
283
+ ### Documentation
284
+
285
+ - The install template's social-login section now states that these fields come
286
+ from the plugin gems, that omitting the gem is what makes them raise, and that
287
+ the `after_initialize` workaround is no longer needed. Documenting "requires
288
+ 0.33.0" alone would have been wrong: uncommenting a line without the provider
289
+ gem in the Gemfile still raises.
290
+
10
291
  ## [0.32.0] - 2026-07-28
11
292
 
12
293
  ### Changed
data/README.md CHANGED
@@ -237,6 +237,20 @@ Resolvers receive keyword arguments with the context containing `client`, `accou
237
237
 
238
238
  ### Social Login Setup
239
239
 
240
+ The `social.google_*` and `social.apple_*` fields are declared by the **provider
241
+ plugin gems**, not by `standard_id` itself — add `standard_id-google` and/or
242
+ `standard_id-apple` to your Gemfile first. Writing a field whose gem is absent
243
+ raises `StandardId::ConfigurationError` ("Unknown field ... for scope
244
+ `social`"), because nothing declared it.
245
+
246
+ With the gem present, a plain initializer is correct: since 0.33.0 `standard_id`
247
+ declares every loaded provider's fields before `:load_config_initializers`, so
248
+ these writes happen after the schema knows about them. On 0.32.0 and earlier the
249
+ fields were declared only from the plugin Railtie's `after_initialize`, so the
250
+ same code raised and apps wrapped the writes in
251
+ `Rails.application.config.after_initialize { ... }`. That wrapper is no longer
252
+ necessary; existing ones keep working unchanged.
253
+
240
254
  ```ruby
241
255
  StandardId.configure do |config|
242
256
  # Google OAuth
@@ -0,0 +1,164 @@
1
+ module StandardId
2
+ module Api
3
+ module Oauth
4
+ # RFC 7662 OAuth 2.0 Token Introspection (POST /oauth/introspect).
5
+ #
6
+ # Off by default. The endpoint is fully absent (404) unless
7
+ # `StandardId.config.oauth.introspection_enabled` is true, mirroring how
8
+ # `dynamic_registration_enabled` gates `/oauth/register`: an endpoint that
9
+ # answers questions about other people's tokens is not something to expose
10
+ # by accident.
11
+ #
12
+ # Confidential clients only (RFC 7662 §2.1 requires the caller be
13
+ # authorized). Credentials arrive as HTTP Basic or in the form body, per
14
+ # RFC 6749 §2.3.1 — never both.
15
+ #
16
+ # Every failure mode renders `{"active": false}` with **no other members**,
17
+ # per RFC 7662 §2.2. That includes bad client credentials, a blank token,
18
+ # a garbage token, a revoked token, and a tripped rate limit. The endpoint
19
+ # deliberately reveals nothing beyond that one bit.
20
+ #
21
+ # ## What introspection can and cannot tell you here
22
+ #
23
+ # **Read this before building an authorization gate on it.**
24
+ #
25
+ # Access tokens are stateless: this engine never persists them and they
26
+ # carry no `sid`, so there is nothing to look up. A revoked session's
27
+ # access token therefore introspects as **`active: true` until its `exp`**.
28
+ # Introspection of an access token answers "was this minted by us, and is
29
+ # it unexpired?" — not "is it still honoured?". The only mitigation is a
30
+ # short `access_token_lifetime`; a caller who assumes otherwise will build
31
+ # a gate that keeps honouring revoked credentials for a full token
32
+ # lifetime.
33
+ #
34
+ # Refresh tokens ARE persisted (as `SHA256(jti)`), so those are checked
35
+ # against the row: a revoked or expired refresh token introspects as
36
+ # inactive, correctly and immediately.
37
+ class IntrospectionsController < BaseController
38
+ public_controller
39
+
40
+ skip_before_action :validate_content_type!, raise: false
41
+
42
+ # The `with:` renders `{active: false}` / 200 — NEVER 429. THIS IS
43
+ # DELIBERATE; DO NOT "FIX" IT.
44
+ #
45
+ # A 429 distinguishes "you are throttled" from "that token is not
46
+ # valid", which turns the rate limiter into a token-validity oracle: an
47
+ # attacker probes until throttled, then reads the *status code* rather
48
+ # than the body to classify tokens. Returning the ordinary inactive
49
+ # response keeps a throttled probe indistinguishable from a miss.
50
+ #
51
+ # Passing an explicit `with:` also opts out of
52
+ # RateLimitHandling.rate_limit's wrapper (which raises
53
+ # ActionController::TooManyRequests to attach Retry-After) — that is the
54
+ # intent, and the concern documents the opt-out.
55
+ #
56
+ # Keyed on `request.remote_ip`, and this matters:
57
+ #
58
+ # * NOT Rack's `request.ip`, which resolves the forwarding chain
59
+ # against Rack's own trusted-proxy list rather than
60
+ # `config.action_dispatch.trusted_proxies`. Behind a CDN that returns
61
+ # the edge address and collapses every caller in the world into a
62
+ # single bucket.
63
+ # * NOT the `Authorization` header or client_id. `rate_limit` is a
64
+ # before_action, so it runs BEFORE client authentication — at that
65
+ # point the header is unverified, attacker-controlled input, and a
66
+ # caller could mint a fresh bucket per request by rotating it. The
67
+ # limit would evaporate exactly when it is needed.
68
+ rate_limit to: StandardId.config.rate_limits.introspection_per_ip,
69
+ within: 15.minutes,
70
+ name: "introspection-ip",
71
+ by: -> { request.remote_ip },
72
+ with: -> { render_inactive },
73
+ only: :create,
74
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
75
+
76
+ before_action :require_introspection_enabled!
77
+
78
+ # POST /oauth/introspect
79
+ def create
80
+ return render_inactive if authenticate_client!.nil?
81
+
82
+ token = params[:token].to_s
83
+ return render_inactive if token.blank?
84
+
85
+ payload = decode_token(token)
86
+ return render_inactive if payload.nil?
87
+
88
+ # Persisted refresh tokens are checkable; access tokens are not.
89
+ persisted = StandardId::RefreshToken.find_by_jti(payload[:jti].to_s) if payload[:jti].present?
90
+ return render_inactive if persisted && !persisted.active?
91
+
92
+ render json: active_response(payload, persisted), status: :ok
93
+ end
94
+
95
+ private
96
+
97
+ # 404 (not 403) when the feature is off, so the endpoint is
98
+ # indistinguishable from one that does not exist.
99
+ def require_introspection_enabled!
100
+ head(:not_found) unless StandardId.config.oauth.introspection_enabled
101
+ end
102
+
103
+ # @return [StandardId::ClientSecretCredential, nil]
104
+ def authenticate_client!
105
+ client_id, client_secret = client_credentials
106
+ return nil if client_id.blank? || client_secret.blank?
107
+
108
+ credential = StandardId::ClientSecretCredential.active.find_by(client_id: client_id)
109
+ return nil unless credential&.authenticate_client_secret(client_secret)
110
+
111
+ credential
112
+ end
113
+
114
+ # RFC 6749 §2.3.1 — Basic auth OR request body, never both. Unlike the
115
+ # token endpoint (which raises InvalidRequestError so a misconfigured
116
+ # client gets told), sending both here is just another `active: false`:
117
+ # this endpoint reports nothing but that one bit.
118
+ def client_credentials
119
+ header = request.headers["Authorization"]
120
+ return [params[:client_id].to_s, params[:client_secret].to_s] unless header&.start_with?("Basic ")
121
+ return [nil, nil] if params[:client_id].present? || params[:client_secret].present?
122
+
123
+ decoded = Base64.strict_decode64(header.split(" ", 2).last)
124
+ id, secret = decoded.split(":", 2)
125
+ [CGI.unescape(id.to_s), CGI.unescape(secret.to_s)]
126
+ rescue ArgumentError
127
+ [nil, nil]
128
+ end
129
+
130
+ # JwtService.decode already returns nil for malformed tokens, bad
131
+ # signatures, expired tokens, wrong issuer and bad iat. No
132
+ # allowed_audiences is passed: introspection is audience-agnostic and
133
+ # returns `aud` for the caller to inspect. The rescue is
134
+ # defence-in-depth in case that contract shifts.
135
+ def decode_token(token)
136
+ StandardId::JwtService.decode(token)
137
+ rescue StandardId::InvalidAudienceError, StandardId::InvalidTokenError, JWT::DecodeError
138
+ nil
139
+ end
140
+
141
+ # RFC 7662 §2.2. `.compact` so absent claims are omitted rather than
142
+ # emitted as null.
143
+ def active_response(payload, persisted)
144
+ {
145
+ active: true,
146
+ token_type: persisted ? "refresh_token" : "Bearer",
147
+ sub: payload[:sub],
148
+ aud: payload[:aud],
149
+ iss: payload[:iss],
150
+ exp: payload[:exp],
151
+ iat: payload[:iat],
152
+ jti: payload[:jti],
153
+ client_id: payload[:client_id],
154
+ scope: payload[:scope]
155
+ }.compact
156
+ end
157
+
158
+ def render_inactive
159
+ render json: { active: false }, status: :ok
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -4,6 +4,9 @@ module StandardId
4
4
  class RevocationsController < BaseController
5
5
  VALID_REVOCATION_SCOPES = %i[account grant].freeze
6
6
 
7
+ # Carried on every SESSION_REVOKED event this endpoint causes.
8
+ REVOCATION_REASON = "token_revocation".freeze
9
+
7
10
  public_controller
8
11
 
9
12
  skip_before_action :validate_content_type!
@@ -56,14 +59,15 @@ module StandardId
56
59
  # credentials with their own lifecycle, not something an interactive
57
60
  # client's logout should silently kill.
58
61
  def revoke_account_sessions!(account_id)
59
- sessions = StandardId::DeviceSession.where(account_id: account_id).active.to_a
60
- return if sessions.empty?
62
+ result = StandardId::DeviceSession
63
+ .active
64
+ .revoke_all_for!(account_id, reason: REVOCATION_REASON)
65
+ return if result.sessions_revoked.zero?
61
66
 
62
- refresh_tokens_revoked = revoke_sessions!(sessions)
63
67
  emit_token_revoked(
64
68
  account_id: account_id,
65
- sessions_revoked: sessions.size,
66
- refresh_tokens_revoked: refresh_tokens_revoked
69
+ sessions_revoked: result.sessions_revoked,
70
+ refresh_tokens_revoked: result.refresh_tokens_revoked
67
71
  )
68
72
  end
69
73
 
@@ -100,7 +104,9 @@ module StandardId
100
104
  # (RefreshToken#session_id is nil unless the flow sets it).
101
105
  session = record.session
102
106
  sessions = (session && session.active?) ? [session] : []
103
- refresh_tokens_revoked += revoke_sessions!(sessions, account: record.account)
107
+ refresh_tokens_revoked += StandardId::Session.revoke_sessions!(
108
+ sessions, account: record.account, reason: REVOCATION_REASON
109
+ ).refresh_tokens_revoked
104
110
 
105
111
  emit_token_revoked(
106
112
  account_id: payload[:sub],
@@ -109,61 +115,6 @@ module StandardId
109
115
  )
110
116
  end
111
117
 
112
- # Bulk-revoke in two queries (one UPDATE per table) instead of
113
- # issuing session.revoke! per row, which would be O(N) UPDATEs plus
114
- # another O(N) cascades to refresh_tokens.
115
- #
116
- # Tradeoff: update_all skips ActiveRecord callbacks, so the per-row
117
- # SESSION_REVOKED event emitted by Session#revoke! is not fired
118
- # automatically. We re-emit it explicitly below so audit-trail
119
- # subscribers (account status/locking, etc.) still see one event
120
- # per revoked session — the semantics are preserved, only the SQL
121
- # shape has changed.
122
- #
123
- # @return [Integer] number of refresh-token rows revoked by the cascade
124
- def revoke_sessions!(sessions, account: nil)
125
- return 0 if sessions.empty?
126
-
127
- now = Time.current
128
- session_ids = sessions.map(&:id)
129
- refresh_tokens_revoked = 0
130
-
131
- ActiveRecord::Base.transaction do
132
- StandardId::Session.where(id: session_ids).update_all(revoked_at: now)
133
- refresh_tokens_revoked = StandardId::RefreshToken
134
- .where(session_id: session_ids, revoked_at: nil)
135
- .update_all(revoked_at: now)
136
- end
137
-
138
- # DB state is already committed above; event publishing is best-effort
139
- # audit emission. A failing subscriber must not short-circuit the loop
140
- # and leave later sessions without their SESSION_REVOKED event, which
141
- # would permanently desync audit-trail consumers from the DB.
142
- #
143
- # All sessions here belong to the same account (both callers scope by
144
- # account), so we load the account once rather than calling
145
- # session.account per row, which would issue N extra SELECTs.
146
- shared_account = account || sessions.first.account
147
- sessions.each do |session|
148
- session.revoked_at = now
149
- begin
150
- StandardId::Events.publish(
151
- StandardId::Events::SESSION_REVOKED,
152
- session: session,
153
- account: shared_account,
154
- reason: "token_revocation"
155
- )
156
- rescue StandardError => e
157
- StandardId.logger.error(
158
- "[StandardId::Revocations] Failed to publish SESSION_REVOKED " \
159
- "for session #{session.id}: #{e.class}: #{e.message}"
160
- )
161
- end
162
- end
163
-
164
- refresh_tokens_revoked
165
- end
166
-
167
118
  def emit_token_revoked(account_id:, sessions_revoked:, refresh_tokens_revoked:)
168
119
  StandardId::Events.publish(
169
120
  StandardId::Events::OAUTH_TOKEN_REVOKED,
@@ -19,17 +19,20 @@ module StandardId
19
19
  public_controller
20
20
 
21
21
  def show
22
- issuer = StandardId.config.issuer
22
+ resolved = StandardId::Oauth::DiscoveryResolver.resolve(request: request)
23
23
 
24
- unless issuer.present?
24
+ unless resolved[:issuer].present?
25
25
  render json: { error: "Issuer not configured" }, status: :not_found
26
26
  return
27
27
  end
28
28
 
29
29
  response.headers["Cache-Control"] = "public, max-age=3600"
30
30
  render json: StandardId::Oauth::DiscoveryDocument.build(
31
- issuer,
32
- registration_enabled: StandardId.config.oauth.dynamic_registration_enabled
31
+ resolved[:issuer],
32
+ endpoint_base: resolved[:endpoint_base],
33
+ registration_enabled: StandardId.config.oauth.dynamic_registration_enabled,
34
+ introspection_enabled: StandardId.config.oauth.introspection_enabled,
35
+ overrides: resolved[:overrides]
33
36
  )
34
37
  end
35
38
  end
@@ -6,17 +6,20 @@ module StandardId
6
6
  public_controller
7
7
 
8
8
  def show
9
- issuer = StandardId.config.issuer
9
+ resolved = StandardId::Oauth::DiscoveryResolver.resolve(request: request)
10
10
 
11
- unless issuer.present?
11
+ unless resolved[:issuer].present?
12
12
  render json: { error: "Issuer not configured" }, status: :not_found
13
13
  return
14
14
  end
15
15
 
16
16
  response.headers["Cache-Control"] = "public, max-age=3600"
17
17
  render json: StandardId::Oauth::DiscoveryDocument.build(
18
- issuer,
19
- registration_enabled: StandardId.config.oauth.dynamic_registration_enabled
18
+ resolved[:issuer],
19
+ endpoint_base: resolved[:endpoint_base],
20
+ registration_enabled: StandardId.config.oauth.dynamic_registration_enabled,
21
+ introspection_enabled: StandardId.config.oauth.introspection_enabled,
22
+ overrides: resolved[:overrides]
20
23
  )
21
24
  end
22
25
  end