standard_id 0.32.0 → 0.34.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: 984924490081d96a84ec4570541802d70414cac9dcf9dd325b8aba6ee7af7be2
4
+ data.tar.gz: c132d58daeea53090156fe42bfd3b6a7b8d8a2393d7ae7a3766e0c4ece0e8a6d
5
5
  SHA512:
6
- metadata.gz: 423dc3b53caf3fad0e0ba2ea41230627a07328e27fdb29439bfaaafb0701b1887f78744321c2579ac8018741a96fda684230d488604702b865e5b8193ebecce4
7
- data.tar.gz: c16d76284752125e7191ca9ae7770a9fed1c3f0af6078f0221bfb294eaddf78f16701c1954147467f9118916d9d662d50dae5b6844589ffc76d65b14af406ab2
6
+ metadata.gz: 65dac8337e2bfa0784d43308d4c29eeeb8208c3b06b721a3f1a2f040eec1916ba35c00b8e69945f72c80a7240b95cb17ed4bf46c6939fe8c78508c44bf3363b9
7
+ data.tar.gz: eb1acc6703201fa5955494aec635f1821ea4d9689b49f73703736f853deb166b5eeaec1adcb20fe79c083f15903301e60829da0497ec0e138af49d2f406e4b02
data/CHANGELOG.md CHANGED
@@ -7,6 +7,297 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.34.0] - 2026-07-31
11
+
12
+ ### Added
13
+
14
+ - **`config.verify_issuer`** — decouples MINTING an `iss` claim from REQUIRING one. `nil` (default) follows `config.issuer`, which is the historic behaviour and changes nothing. Set `false` to stamp `iss` on new tokens without yet verifying it.
15
+
16
+ This exists because the two were one switch, which made adopting an issuer a flag day: every token already in flight was minted without an `iss`, so setting `config.issuer` rejected all of them at once — every access token and, far worse, every refresh token. An app that had never configured an issuer had no safe single step, which is where `nutripod-web` was stuck (rarebit-one/nutripod-web#1111) and why it could not retire its hand-rolled discovery controller with the rest of the estate.
17
+
18
+ Migration: set `issuer` with `verify_issuer = false`, wait out `refresh_token_lifetime` (the long pole — access tokens are short), then remove the override. Setting `true` with no issuer raises at boot rather than silently verifying against `nil` and accepting anything.
19
+
20
+ ## [0.33.0] - 2026-07-30
21
+
22
+ ### Added
23
+
24
+ - **RFC 7662 token introspection** — `POST /oauth/introspect`, behind
25
+ `config.oauth.introspection_enabled` (default **false**). When off the
26
+ endpoint returns 404 and `introspection_endpoint` is not advertised in either
27
+ discovery document, mirroring how `dynamic_registration_enabled` gates
28
+ `/oauth/register`.
29
+
30
+ Confidential clients only: `client_id` + `client_secret`, via HTTP Basic or
31
+ the form body (RFC 6749 §2.3.1 — never both). Every failure mode renders
32
+ `{"active": false}` with **no other members**, per RFC 7662 §2.2.
33
+
34
+ Throttled per IP via `config.rate_limits.introspection_per_ip` (default 30 per
35
+ 15 minutes).
36
+
37
+ Two properties are load-bearing and deliberately not what you would reach for:
38
+
39
+ - **The rate limit renders `{"active": false}` / 200, never 429.** A 429
40
+ distinguishes "you are throttled" from "that token is not valid", which turns
41
+ the limiter into a token-validity oracle — an attacker probes until throttled
42
+ and then reads the *status code* to classify tokens. This opts out of
43
+ `RateLimitHandling.rate_limit`'s wrapper by passing an explicit `with:`. The
44
+ reason is commented at the call site, because it reads like a bug otherwise.
45
+ - **The limit keys on `request.remote_ip`** — not Rack's `request.ip`, which
46
+ resolves the forwarding chain against Rack's own trusted-proxy list rather
47
+ than `config.action_dispatch.trusted_proxies` and therefore collapses every
48
+ caller behind a CDN into one bucket. And never on the `Authorization` header
49
+ or `client_id`: `rate_limit` is a `before_action` that runs *before* client
50
+ authentication, so a header-derived key is attacker-controlled and a caller
51
+ could mint a fresh bucket per request by rotating it.
52
+
53
+ **Know this limit before building an authorization gate on it.** Access tokens
54
+ are stateless — never persisted, carrying no `sid` — so **a revoked session's
55
+ access token introspects as `active: true` until its `exp`**. Introspection
56
+ answers "did we mint this, and is it unexpired?", not "is it still honoured?".
57
+ The only mitigation is a short access-token lifetime. Refresh tokens *are*
58
+ persisted (as `SHA256(jti)`) and are checked against the row, so a revoked or
59
+ expired refresh token introspects as inactive immediately, and is reported with
60
+ `token_type: "refresh_token"`. Both halves are asserted by specs, the access-token
61
+ one deliberately, so nobody later "corrects" the documentation to overclaim.
62
+
63
+ - `config.oauth.introspection_enabled` and
64
+ `config.rate_limits.introspection_per_ip`, both documented inline in the
65
+ install template.
66
+
67
+
68
+ - `StandardId::ProviderRegistry.declare_config_schemas!`,
69
+ `.declare_config_schema(provider_class)` and `.provider_classes` — public
70
+ entry points for the above. Useful if you define a provider somewhere the
71
+ boot-time sweep cannot see it (e.g. under `app/`, autoloaded) and need to
72
+ declare its fields yourself.
73
+
74
+ - **`config.association_strict_loading`** — a supported way to opt the gem's own
75
+ associations out of an app-wide `strict_loading_by_default = true`.
76
+
77
+ Two consuming apps were reaching into Rails internals to do this:
78
+
79
+ ```ruby
80
+ Account.reflect_on_association(assoc)&.options&.[]=(:strict_loading, false)
81
+ ```
82
+
83
+ both with a comment asking for exactly this hook. They could not simply
84
+ re-declare the associations: they are declared inside
85
+ `StandardId::AccountAssociations`, and `credentials` is a `has_many :through`,
86
+ where re-declaration risks ordering breakage. Passing `strict_loading:` at
87
+ declaration time is the supported Rails API, and unlike re-declaration it
88
+ works for the `:through` association.
89
+
90
+ Covers `Account#identifiers`, `#credentials`, `#sessions`, `#refresh_tokens`,
91
+ `#client_applications`, plus `Session#refresh_tokens` and
92
+ `Identifier#credentials`.
93
+
94
+ **Tri-state, and `nil` is not `false`:**
95
+
96
+ | value | effect |
97
+ |---|---|
98
+ | `nil` (default) | no `strict_loading:` option is declared at all — the associations inherit the owner's setting, exactly as before |
99
+ | `false` | opt out; may lazy-load even with strict loading on app-wide |
100
+ | `true` | opt in; strict loading enforced even if off app-wide |
101
+
102
+ That distinction is load-bearing rather than stylistic. Rails checks
103
+ `reflection.options.key?(:strict_loading)` *before* consulting the owner
104
+ (`Association#violates_strict_loading?`), so declaring `strict_loading: nil`
105
+ would put the key in the options hash and make `reflection.strict_loading?`
106
+ return `false` — silently disabling strict loading on every gem association in
107
+ every app that never asked for it. The option is therefore omitted entirely
108
+ when unconfigured, and a spec asserts the key's absence.
109
+
110
+ **Set it in `config/initializers/standard_id.rb`, not in an
111
+ `after_initialize` block.** The associations read it when your `Account` class
112
+ body runs. StandardId raises a `ConfigurationError` at boot naming the ordering
113
+ problem if a declaration disagrees with the configured value — otherwise a
114
+ too-late assignment fails silently, surfacing as an
115
+ `ActiveRecord::StrictLoadingViolationError` deep inside a request or a quiet
116
+ N+1 in production.
117
+
118
+ - **`StandardId::Session.revoke_all_for!(account, reason:)`** — bulk session
119
+ revocation that cascades to refresh tokens, promoted from the private
120
+ `RevocationsController#revoke_sessions!` where it had lived since the RFC 7009
121
+ endpoint was built.
122
+
123
+ Consuming apps that bulk-revoked with a bare
124
+ `Session.where(account:).update_all(revoked_at:)` were silently skipping the
125
+ refresh-token cascade that `Session#revoke!` performs, leaving the holder of a
126
+ refresh token able to keep minting access tokens after a password reset or
127
+ account deactivation — and skipping the `session.revoked` event, so
128
+ audit-trail and account-locking subscribers never learned. This gives them a
129
+ supported API instead of a local re-implementation.
130
+
131
+ Two set-based UPDATEs (one per table) rather than `sessions.each(&:revoke!)`,
132
+ which is O(N) UPDATEs plus O(N) cascades — these call sites are admin bulk
133
+ actions and password resets.
134
+
135
+ ```ruby
136
+ result = StandardId::Session.revoke_all_for!(account, reason: "password_reset")
137
+ result.sessions_revoked # => 3
138
+ result.refresh_tokens_revoked # => 5
139
+ ```
140
+
141
+ Honours the current scope, so callers narrow as they need:
142
+ `StandardId::DeviceSession.active.revoke_all_for!(account, reason: "logout")`.
143
+ Accepts an account record or a bare id.
144
+
145
+ **Events: one `SESSION_REVOKED` per revoked session, never a single
146
+ aggregate** — subscribers must not need a second code path for bulk
147
+ revocation. Because `update_all` skips callbacks, each event is re-emitted
148
+ explicitly after the transaction commits, and each is individually rescued: a
149
+ raising subscriber must not short-circuit the loop and leave later sessions
150
+ without their event, which would permanently desync audit consumers from the
151
+ DB. Callers emit their own aggregate from the returned counts.
152
+
153
+ - **`StandardId::Session.revoke_sessions!(sessions, account:, reason:)`** — the
154
+ same set-based core over an explicit collection, for callers that have already
155
+ selected the sessions (e.g. the tokens of a single authorization grant).
156
+
157
+
158
+ - **`config.oauth.discovery_endpoint_base`** — where the advertised endpoints
159
+ actually live.
160
+
161
+ | value | meaning |
162
+ |---|---|
163
+ | `nil` (default) | the issuer — byte-identical to 0.32.0 |
164
+ | `:request` | `request.base_url` + the detected mount path |
165
+ | `"https://…"` | verbatim |
166
+ | `->(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 |
167
+
168
+ Request-derived is **opt-in, not the default**, on purpose: defaulting to it
169
+ would silently rewrite the document of every app whose issuer host differs from
170
+ the host serving the request — the split-host setup a separate issuer exists to
171
+ express.
172
+
173
+ Inside the mount the mount path needs no configuring or detecting: Rails sets
174
+ `SCRIPT_NAME` to the mount prefix, so `request.script_name` *is* the mount
175
+ path, exactly.
176
+
177
+ - **`standard_id_well_known_routes at: "<mount path>"`** — a routes-file mapper
178
+ helper that serves the documents at the **origin root**.
179
+
180
+ RFC 8615 clients probe the origin root, which is outside every engine mount, so
181
+ the gem could not draw those routes itself — this has to live in the host's
182
+ routes file.
183
+
184
+ For each metadata document it draws **both** the bare root form and the RFC
185
+ 8414 §3.1 **path-inserted** form:
186
+
187
+ ```
188
+ /.well-known/oauth-authorization-server
189
+ /.well-known/oauth-authorization-server/api/v1
190
+ ```
191
+
192
+ The second looks redundant and is not. §3.1 inserts the well-known segment
193
+ *before* a path-carrying issuer's path rather than appending to it, and that is
194
+ the URL Claude Code actually requests in production. Without it the client 404s,
195
+ falls back to issuer-relative defaults — hitting the engine's own `/authorize`
196
+ rather than a host audience shim — and every subsequent authenticated request
197
+ 401s, a long way from the cause. It is drawn for you rather than documented as
198
+ an extra step.
199
+
200
+ `only:` / `except:` select among `:oauth_authorization_server`,
201
+ `:openid_configuration`, `:jwks` — `only: :jwks` for an app that keeps its own
202
+ metadata controller but wants the gem's JWKS at the root. `extra_paths:` adds
203
+ further path-inserted suffixes. `path_inserted: false` draws only the bare root
204
+ forms. No path-inserted JWKS is drawn: it is a concrete file path, not a
205
+ document whose location §3.1 relocates.
206
+
207
+ - **`config.oauth.discovery_metadata_overrides`** — the members that cannot be
208
+ derived: an authorization endpoint pointing at a host-owned audience-injecting
209
+ shim (needed by all five apps, and the original reason they each wrote a
210
+ controller), a scope list deliberately narrower than what the server can mint,
211
+ an auth-method list that must mirror the host's dynamic-registration policy.
212
+
213
+ Values are static or callable; a callable receives
214
+ `{ origin:, endpoint_base:, issuer:, request: }` with `origin` carrying no
215
+ path. A **`nil` value removes the member** rather than emitting `null` — real
216
+ apps omit `scopes_supported` entirely, and a typed RFC 8414 member set to null
217
+ is worse than either alternative.
218
+
219
+ **Setting `issuer` raises `StandardId::ConfigurationError`**, naming
220
+ `discovery_endpoint_base` as the thing you probably wanted. It is refused
221
+ rather than ignored because an issuer diverging from what the token service
222
+ stamps yields a document that validates against nothing, failing far from its
223
+ cause.
224
+
225
+ See `docs/MIGRATION_GUIDE.md` for how to retire a hand-rolled controller.
226
+
227
+ ### Fixed
228
+
229
+ - **The discovery documents no longer ignore the `ApiEngine` mount prefix.**
230
+
231
+ `Oauth::DiscoveryDocument` derived every endpoint from the issuer
232
+ (`base = issuer.to_s.chomp("/")`). An app mounting `ApiEngine` under a prefix
233
+ its issuer does not carry — `/api`, `/api/v1` — therefore advertised
234
+ `<issuer>/oauth/token` while the endpoint actually lived at
235
+ `<origin>/api/oauth/token`. **Every advertised endpoint 404'd.** Both documents
236
+ were affected; the OIDC one had the identical bug.
237
+
238
+ All five consuming apps hand-rolled a replacement controller because of it.
239
+
240
+ `issuer` and the endpoint base are now separate values, and stay separate.
241
+ `issuer` is a stable security identifier (RFC 8414 §2) that clients match
242
+ byte-for-byte against both their discovery URL and the `iss` claim of issued
243
+ tokens: it is never derived from the request and cannot be overridden.
244
+
245
+
246
+ - `Session.revoke_sessions!` no longer aborts the revocation loop when logging
247
+ itself fails. The rescue around each `SESSION_REVOKED` publish exists so a
248
+ failing subscriber cannot short-circuit the loop and leave later sessions
249
+ without their event — but it logged via `StandardId.logger.error`, and
250
+ `StandardId.logger` is a *memoized* `config.logger || Rails.logger`, so
251
+ whatever the first reader in the process saw is what every later caller gets,
252
+ including a value that is not a logger at all. In that case the rescue raised
253
+ from inside itself and the guarantee evaporated. It now checks the logger
254
+ responds to `error` first.
255
+
256
+ - **Provider plugin config fields are now declared before host initializers
257
+ run**, so `config.social.google_client_id = ...` works in a plain
258
+ `config/initializers/standard_id.rb` — the way the install template, this
259
+ README, and both plugin READMEs all said it did.
260
+
261
+ It did not. `social.google_client_id` is not in the core schema; the plugin
262
+ declares it via `Providers::Google.config_schema`, which reached
263
+ `ConfigSchema.add_field` only through `ProviderRegistry.register`, called from
264
+ the plugin Railtie's `config.after_initialize` — long after
265
+ `:load_config_initializers`. The host's write therefore hit
266
+ `ConfigSchema::Scope#[]=` → `validate!` against a schema that did not yet know
267
+ the field and raised `StandardId::ConfigurationError: Unknown field
268
+ 'google_client_id' for scope 'social'`, with nothing in the message to suggest
269
+ the cause was boot ordering. Every consuming app that used a provider plugin
270
+ independently rediscovered the same
271
+ `Rails.application.config.after_initialize { ... }` workaround, and the three
272
+ documented forms disagreed with each other — two of them raised.
273
+
274
+ A new core Engine initializer, `standard_id.provider_config_schemas`, runs
275
+ `before: :load_config_initializers` and declares the fields of every loaded
276
+ provider class. **No plugin release is required**: provider classes are
277
+ required at gem-require time, so they are already loaded at that point.
278
+
279
+ Only *field declaration* moved earlier. Full `ProviderRegistry.register` —
280
+ which also runs `validate_provider!` and the provider's `setup` — stays in
281
+ `after_initialize`, where host configuration is complete.
282
+
283
+ **Existing `after_initialize` wrappers keep working verbatim.**
284
+ `add_field` is retroactive: `Scope#validate!` and `Scope#[]` both consult the
285
+ schema live, the latter falling back to the field's declared default for an
286
+ unwritten key, so declaring a field late is indistinguishable from declaring
287
+ it early. There is nothing to migrate.
288
+
289
+ The dummy app now loads a stand-in provider at application-require time and
290
+ writes its fields from an ordinary initializer, so a regression stops the app
291
+ booting rather than failing one assertion.
292
+
293
+ ### Documentation
294
+
295
+ - The install template's social-login section now states that these fields come
296
+ from the plugin gems, that omitting the gem is what makes them raise, and that
297
+ the `after_initialize` workaround is no longer needed. Documenting "requires
298
+ 0.33.0" alone would have been wrong: uncommenting a line without the provider
299
+ gem in the Gemfile still raises.
300
+
10
301
  ## [0.32.0] - 2026-07-28
11
302
 
12
303
  ### 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