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 +4 -4
- data/CHANGELOG.md +281 -0
- data/README.md +14 -0
- data/app/controllers/standard_id/api/oauth/introspections_controller.rb +164 -0
- data/app/controllers/standard_id/api/oauth/revocations_controller.rb +12 -61
- data/app/controllers/standard_id/api/well_known/oauth_authorization_server_controller.rb +7 -4
- data/app/controllers/standard_id/api/well_known/openid_configuration_controller.rb +7 -4
- data/app/models/concerns/standard_id/account_associations.rb +18 -5
- data/app/models/standard_id/identifier.rb +4 -1
- data/app/models/standard_id/session.rb +116 -1
- data/config/routes/api.rb +5 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +122 -2
- data/lib/standard_id/association_strict_loading.rb +121 -0
- data/lib/standard_id/config/schema.rb +97 -0
- data/lib/standard_id/engine.rb +33 -0
- data/lib/standard_id/oauth/discovery_document.rb +73 -12
- data/lib/standard_id/oauth/discovery_resolver.rb +158 -0
- data/lib/standard_id/provider_registry.rb +66 -13
- data/lib/standard_id/routing.rb +133 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +3 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c5341add412ad2ef395a4252d31f751e5b9eeb9df585f1eb3e5c276d7071211f
|
|
4
|
+
data.tar.gz: b53b6dc29f5aa4a5994b040f5a6cdca2441c4c0c57cb2449b1fc1aead9caf4a7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
60
|
-
|
|
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:
|
|
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!(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|