doorkeeper 6.0.0.beta1 → 6.0.0.beta2

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/README.md +203 -0
  4. data/app/controllers/doorkeeper/applications_controller.rb +38 -9
  5. data/app/controllers/doorkeeper/authorizations_controller.rb +17 -3
  6. data/app/controllers/doorkeeper/tokens_controller.rb +5 -1
  7. data/app/views/doorkeeper/authorizations/new.html.erb +18 -0
  8. data/config/locales/en.yml +2 -0
  9. data/lib/doorkeeper/client_authentication/legacy_callable.rb +6 -0
  10. data/lib/doorkeeper/client_authentication/method.rb +17 -0
  11. data/lib/doorkeeper/client_authentication/verified_credentials.rb +19 -0
  12. data/lib/doorkeeper/client_authentication.rb +9 -0
  13. data/lib/doorkeeper/config.rb +47 -0
  14. data/lib/doorkeeper/document_cache.rb +81 -0
  15. data/lib/doorkeeper/errors.rb +48 -0
  16. data/lib/doorkeeper/http_fetcher.rb +232 -0
  17. data/lib/doorkeeper/models/access_grant_mixin.rb +16 -0
  18. data/lib/doorkeeper/models/access_token_mixin.rb +42 -1
  19. data/lib/doorkeeper/oauth/authorization/code.rb +10 -0
  20. data/lib/doorkeeper/oauth/authorization/token.rb +13 -2
  21. data/lib/doorkeeper/oauth/authorization_code_request.rb +123 -2
  22. data/lib/doorkeeper/oauth/base_request.rb +1 -2
  23. data/lib/doorkeeper/oauth/client.rb +6 -0
  24. data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +4 -0
  25. data/lib/doorkeeper/oauth/client_authentication/client_secret_post.rb +4 -0
  26. data/lib/doorkeeper/oauth/client_authentication/none.rb +47 -8
  27. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/key_resolver.rb +104 -0
  28. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/replay_guard.rb +78 -0
  29. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +247 -0
  30. data/lib/doorkeeper/oauth/client_credentials/creator.rb +25 -9
  31. data/lib/doorkeeper/oauth/client_credentials_request.rb +36 -5
  32. data/lib/doorkeeper/oauth/helpers/uri_checker.rb +35 -23
  33. data/lib/doorkeeper/oauth/metadata_response.rb +8 -0
  34. data/lib/doorkeeper/oauth/password_access_token_request.rb +27 -1
  35. data/lib/doorkeeper/oauth/pre_authorization.rb +51 -3
  36. data/lib/doorkeeper/oauth/refresh_token_request.rb +51 -1
  37. data/lib/doorkeeper/oauth/resource_indicator_validator.rb +70 -0
  38. data/lib/doorkeeper/oauth/scopes.rb +25 -0
  39. data/lib/doorkeeper/oauth/token_introspection.rb +6 -0
  40. data/lib/doorkeeper/version.rb +1 -1
  41. data/lib/doorkeeper.rb +4 -0
  42. data/lib/generators/doorkeeper/grant_reuse_revocation_generator.rb +43 -0
  43. data/lib/generators/doorkeeper/resource_indicators_generator.rb +33 -0
  44. data/lib/generators/doorkeeper/templates/add_access_token_to_access_grants.rb.erb +10 -0
  45. data/lib/generators/doorkeeper/templates/enable_resource_indicators_migration.rb.erb +8 -0
  46. data/lib/generators/doorkeeper/templates/initializer.rb +65 -0
  47. data/lib/generators/doorkeeper/templates/migration.rb.erb +13 -0
  48. metadata +45 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 454edd4f496ec3750a0be3da7db728302d93b355c075d6aded28792c8a53b9a8
4
- data.tar.gz: 8be2862ad184cb71e36a5b9d2747bfc5186fe8bf9c539b6d61fbe92d05a57e5c
3
+ metadata.gz: b401160f58e48398c2e25631a27e5dabc423480e08f34a64c91c25107ffcab53
4
+ data.tar.gz: 6c6dc01f00118c8525fe7ec329cab28cdaad05b5c5b06402cbb7f59ceb565e1a
5
5
  SHA512:
6
- metadata.gz: 3cb1367a63c3404a1dc79dd910265e5c189da7e8745b40c48f46e06d9b326f7a215760747c0273e28a51a26ff8e6447c376faf6a169d9f93cec84aac97191022
7
- data.tar.gz: 3a3dc94969468b7ff5ef364876d068bf8fde231fcaeaa5ea6e43c207f7aafb236f02b5264b32678ad100788a1deb99caee79a3701cca1b4f79fd3a7f487865f1
6
+ metadata.gz: c394405b84f4b64de4db90e9ab8ea932f2899a2f3898d78df4d06add7765c6f5ae7a457e8e3c19ed69bf7592ffec8c3d1ab5c241f1a521e0347780ead79c233d
7
+ data.tar.gz: 2275e90f19537ae4cda5e60ad1571ec29e0468e53b84d91bb165dea68960c2bf60d358194373ebd183b688d9043d86ef3a0e3685c0c38c7d4db8822ee7a98534
data/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ User-visible changes worth mentioning.
7
7
 
8
8
  ## main
9
9
 
10
+ - [#PR ID] Description of the change.
11
+
12
+ ## 6.0.0.beta2
13
+
14
+ **Please make sure** you read the [Upgrade guides](https://github.com/doorkeeper-gem/doorkeeper/wiki/Migration-from-old-versions)
15
+ and changelog below before the update since this version includes breaking changes.
16
+
17
+ - [#1865] Revoke the token issued for an authorization code when the code is exchanged more than once, per RFC 6749 §4.1.2 / §10.5. Active when the `oauth_access_grants.access_token_id` column exists: new installs get it from the generated migration, existing apps can add it with `rails generate doorkeeper:grant_reuse_revocation`. Closes [#1713].
18
+ - [#1871] **[BREAKING]** `redirect_uri` is now compared to the registered redirect URIs with the simple string comparison required by RFC 6749 §3.1.2.3 (the RFC 8252 §7.3 loopback port exception is kept). Clients relying on the previous lenient matching must send the exact registered URI, closes [#1718].
19
+ - [#1874] Fix `force_pkce` requiring a `code_challenge` from response types that never issue an authorization code (e.g. `token`, or an OIDC extension's `id_token` / `id_token token`). PKCE (RFC 7636) protects the authorization code exchange, so for code-less response types there is no token-endpoint step where a `code_verifier` could ever be checked — such requests were rejected over a parameter that cannot be validated. `force_pkce` now only enforces the challenge for response types that issue a code (`code` and code-carrying hybrid types such as `code id_token`).
20
+ - [#1876] Fix: reject a non-string `scope` parameter (e.g. `scope[a]=b`, which Rack parses into a Hash) with `invalid_request` (RFC 6749 §3.3) instead of an unhandled 500. `Scopes.from_string` now raises `Errors::InvalidScopeParameter` for a non-string argument — turned into `invalid_request` by the token endpoint's `rescue_from`, so every grant type is covered — and the authorization endpoint rejects it up front in pre-authorization validation. The crash was reachable unauthenticated, before client authentication.
21
+ - [#1877] Fix: let the `none` client authentication strategy match a request whose `Authorization` header carries a Bearer token. A bearer credential authorizes access to the endpoint itself (e.g. a bearer-protected introspection endpoint per RFC 7662 §2.1, or a revocation request) rather than authenticating the client, so it must not suppress the public-client `none` strategy when the client identifies itself with a body `client_id`. Any other non-blank `Authorization` value — Basic, or a `Bearer` with no token — is still treated as header-based client authentication and continues to bypass `none`. The 6.0.0.beta1 workaround of dropping the `Authorization` header from such a request is no longer needed.
22
+ - [#1878] Fix: the loopback redirect URI exception (RFC 8252 §7.3) now varies by port only, not by userinfo. The port was cleared with `URI#port=`, which on Ruby >= 4.0 also drops the userinfo, so `http://attacker@127.0.0.1/cb` matched a registered `http://127.0.0.1/cb`. The match is now made component-by-component. The destination host is always the loopback interface, so this was not a cross-origin open redirect, and non-loopback hosts were never affected.
23
+ - [#1879] Fix: with `reuse_access_token` enabled, replaying an authorization code no longer revokes an access token that another grant still shares. The single-use revocation added in [#1865] followed the grant's `access_token_id`, which a reused token shares across grants, so a replay could collaterally revoke a token another valid session still held. The revocation now skips a token referenced by another grant and only reaches one unique to the replayed code; a token unique to the code is still revoked as before.
24
+ - [#1881] Fix: `Doorkeeper::ApplicationsController` no longer 500s on `create`/`update`/`destroy` in `api_only` mode, where `ActionController::API` provides no `flash`. Confining `flash` to the HTML path is not sufficient on its own, because a client that does not name JSON explicitly still negotiates its way into that path — an absent `Accept` header and a browser-like list such as `application/json, text/plain, */*` both resolve to `text/html`, and a bare `*/*` resolves to the first registered format — so `api_only` mode now pins the response format to JSON.
25
+ - [#1883] Internal: merge `CHANGELOG.md` with git's `union` driver, so two pull requests that each add an entry no longer conflict on the line above "Please add here".
26
+ - [#1884] Fix: `/oauth/introspect` and `/oauth/revoke` extend the token lookup across both token types when the lookup by `token_type_hint` finds nothing (RFC 7662 §2.1 / RFC 7009 §2.1), so a wrong hint no longer hides a token the server knows about ([#1882])
27
+ - [#1885] Index `oauth_access_grants.access_token_id` in the migration templates: since [#1879] the code-replay revocation filters access grants by that column, the "never used to filter queries" premise behind `index: false` no longer holds.
28
+ - [#1886] Add support for Resource Indicators for OAuth 2.0 (RFC 8707). Clients can include a `resource` parameter in authorization and token requests to indicate the target protected resource(s). The authorization server validates resource URIs, enforces audience restriction on tokens, and includes `aud` in introspection responses. Enable by configuring `resource_indicator_validator` with a callable. Requires new `resource` columns on access grants and tokens — run `rails generate doorkeeper:resource_indicators` to add the migration.
29
+ - [#1887] [test] Pin that a requested non-default scope reaches the authorization grant and the exchanged token, and that the authorization strategy shares the controller's pre-authorization — the mismatch reported in [#1576] does not reproduce. Test-only change, closes [#1576].
30
+ - [#1888] Document custom grant flow registration (`Doorkeeper::GrantFlow.register`) in the README with a SAML 2.0 bearer assertion (RFC 7522) walkthrough, and pin URN-shaped custom grant types with an end-to-end request spec. Docs/test-only change, closes [#764].
31
+ - [#1890] [test] Pin that the authorization endpoint answers `invalid_redirect_uri` for a client registered without a redirect URI (`allow_blank_redirect_uri`), whether or not the request supplies one — the behavior required by RFC 6749 §3.1.2.3. Test-only change, closes [#1682].
32
+ - [#1898] Fix: with `reuse_access_token` enabled, the `client_credentials` grant no longer reuses a token whose `resource` differs from the one requested (RFC 8707), so the audience restriction the client asked for is always applied. Follow-up to [#1886].
33
+ - [#1899] Document the client authentication methods registry (`Doorkeeper::ClientAuthentication.register`) in the README with a walkthrough for registering a custom method, and pin it with an end-to-end request spec. Docs/test-only change, closes [#1894].
34
+ - [#1891] Fix: an authorization request carrying a `redirect_uri` for a client registered without one (`redirect_uri` is `nil` under `allow_blank_redirect_uri`) now answers `invalid_redirect_uri` instead of crashing with an unhandled 500.
35
+ - [#1896] Add an opt-in `private_key_jwt` client authentication method (RFC 7523 / OIDC Core §9, requires the `jwt` gem >= 2.7) that verifies assertions against the client's published public keys — `jwks` / `jwks_uri` attributes you define on your Application model, the latter fetched with an SSRF-hardened HTTP client. The jti replay guard and the fetched-JWKS cache are process-local by default and can be replaced with shared stores via the `private_key_jwt_replay_guard` / `private_key_jwt_jwks_cache` config options. Part of [#1875].
36
+ - [#1901] [test] Pin the answered behaviors behind [#984], [#1554], [#1600], [#1663], [#1759] and [#1787] with regression specs — the built-in client authentication methods, the unmodified echo of long `state` values, DB persistence with custom token generators, refresh token rotation/expiry semantics and the introspection asymmetry. Test-only change, closes those issues along with [#1291], [#1756] and [#1764].
37
+ - [#1902] Fix: requests that omit `scope` now compute the same default scopes at the authorization and token endpoints (`Scopes#common`, symmetric). With dynamic scopes enabled, a scope pattern in either `default_scopes` or the application's scopes grants the matching concrete scope at both endpoints, closes [#1889].
38
+ - [#1903] Fix: `revoke_previous_client_credentials_token` no longer revokes a client's live access token issued for a different `resource` ([#1886]), so a client can keep one audience-restricted token per resource server.
39
+ - [#1905] [test] Cover `private_key_jwt` client authentication on the `client_credentials` grant, the one flow where an assertion is the client's only credential end to end. Test-only change.
10
40
  - Please add here
11
41
 
12
42
  ## 6.0.0.beta1
@@ -47,7 +77,11 @@ and changelog below before the update since this version includes breaking chang
47
77
  - [#1864] Fix `custom_access_token_attributes` values being dropped when the authorization goes through the consent screen: the approve/deny forms now carry the custom attributes as hidden fields, and the pre-authorization JSON (`api_only` mode) includes them so custom consent UIs can send them back.
48
78
  - [#1869] Improve test coverage
49
79
  - [#1870] Fix: raise the intended `Doorkeeper::Errors::TokenGeneratorNotFound` / `UnableToGenerateToken` (instead of a confusing `NameError`) when `application_secret_generator` is misconfigured.
50
- - Please add here
80
+
81
+ ## 5.9.5
82
+
83
+ - [#1901] Reject requests that authenticate the client with more than one method (RFC 6749 §2.3) with an `invalid_request` error, instead of silently authenticating with the first method that matched and discarding the other credentials.
84
+ - [#1853] Fix `reuse_access_token` reusing a token that was created with `custom_access_token_attributes` values when the new request doesn't specify any custom attributes. Such requests now only match tokens without custom attributes.
51
85
 
52
86
  ## 5.9.3
53
87
 
data/README.md CHANGED
@@ -25,6 +25,7 @@ Supported features:
25
25
  - [OAuth 2.0 for Native Apps](https://datatracker.ietf.org/doc/html/rfc8252)
26
26
  - [Proof Key for Code Exchange by OAuth Public Clients](https://datatracker.ietf.org/doc/html/rfc7636)
27
27
  - [OAuth 2.0 Authorization Server Issuer Identification](https://datatracker.ietf.org/doc/html/rfc9207) — opt-in by setting `issuer`; adds the `iss` parameter to authorization redirects returned to the client
28
+ - [Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
28
29
 
29
30
  ## Table of Contents
30
31
 
@@ -38,6 +39,9 @@ Supported features:
38
39
  - [Grape](#grape)
39
40
  - [ORMs](#orms)
40
41
  - [Extensions](#extensions)
42
+ - [Resource Indicators](#resource-indicators)
43
+ - [Custom Grant Flows](#custom-grant-flows)
44
+ - [Custom Client Authentication Methods](#custom-client-authentication-methods)
41
45
  - [Example Applications](#example-applications)
42
46
  - [Sponsors](#sponsors)
43
47
  - [Development](#development)
@@ -107,6 +111,205 @@ Extensions that are not included by default and can be installed separately.
107
111
  | CIBA - Client Initiated Backchannel Authentication Flow extension | [doorkeeper-ciba](https://github.com/autoseg/doorkeeper-ciba) |
108
112
  | Device Authorization Grant | [doorkeeper-device_authorization_grant](https://github.com/exop-group/doorkeeper-device_authorization_grant) |
109
113
 
114
+ ## Resource Indicators
115
+
116
+ Doorkeeper supports [Resource Indicators for OAuth 2.0 (RFC 8707)](https://datatracker.ietf.org/doc/html/rfc8707), allowing clients to signal which protected resource(s) they intend to access. Tokens are then audience-restricted to those resources.
117
+
118
+ ### Setup
119
+
120
+ 1. Run the generator to add the required `resource` column:
121
+
122
+ ```bash
123
+ rails generate doorkeeper:resource_indicators
124
+ rails db:migrate
125
+ ```
126
+
127
+ 2. Configure a validator in your initializer:
128
+
129
+ ```ruby
130
+ # config/initializers/doorkeeper.rb
131
+ Doorkeeper.configure do
132
+ resource_indicator_validator ->(resource_indicators, client) {
133
+ allowed = %w[https://api.example.com/ https://calendar.example.com/]
134
+ resource_indicators.all? { |r| allowed.include?(r) }
135
+ }
136
+ end
137
+ ```
138
+
139
+ The callable receives an array of resource URIs and the OAuth client. Return `true` to accept or `false` to reject with `invalid_target`.
140
+
141
+ ### Behavior
142
+
143
+ - Resource URIs must be absolute and must not contain a fragment component.
144
+ - Resource indicators are stored on grants and tokens.
145
+ - Token and refresh requests enforce subset restrictions against the original grant.
146
+ - Token introspection responses include `aud` when resource indicators are present.
147
+ - Grants issued with resource indicators retain their audience restriction even if the validator is later removed from configuration.
148
+
149
+ ### Multiple resources
150
+
151
+ RFC 8707 uses repeated query parameters (`?resource=…&resource=…`) for multiple values, but Rack collapses repeated keys to the last value. Clients must use the Rails bracket syntax for multiple resource indicators:
152
+
153
+ ```
154
+ ?resource[]=https://api.example.com/&resource[]=https://calendar.example.com/
155
+ ```
156
+
157
+ A single `resource=…` works as-is.
158
+
159
+ ## Custom Grant Flows
160
+
161
+ Besides the built-in OAuth 2 flows, Doorkeeper can recognize and process any custom grant type through its grant flow registry — including grant types whose names are URNs or URIs, such as the SAML 2.0 bearer assertion grant defined by [RFC 7522](https://www.rfc-editor.org/rfc/rfc7522).
162
+
163
+ A grant flow bundles a matcher for the `grant_type` parameter with a strategy class that processes the token request. Register it before `Doorkeeper.configure` and enable it by adding its registered name to `grant_flows`:
164
+
165
+ ```ruby
166
+ # config/initializers/doorkeeper.rb
167
+ Doorkeeper::GrantFlow.register(
168
+ :saml2_bearer,
169
+ grant_type_matches: "urn:ietf:params:oauth:grant-type:saml2-bearer",
170
+ grant_type_strategy: SamlBearer::Strategy,
171
+ )
172
+
173
+ Doorkeeper.configure do
174
+ grant_flows %w[authorization_code saml2_bearer]
175
+ # ...
176
+ end
177
+ ```
178
+
179
+ Note that `grant_flows` lists the *registered flow name* (`saml2_bearer`), while `grant_type_matches` — a `String` or a `Regexp` — is what the request's `grant_type` parameter is matched against.
180
+
181
+ The strategy class receives the authorization server as `server` and builds the request object handling the grant:
182
+
183
+ ```ruby
184
+ module SamlBearer
185
+ class Strategy < Doorkeeper::Request::Strategy
186
+ delegate :client, :parameters, to: :server
187
+
188
+ def request
189
+ @request ||= TokenRequest.new(Doorkeeper.config, client, parameters)
190
+ end
191
+ end
192
+ end
193
+ ```
194
+
195
+ The request object validates the grant and issues the token. Subclassing `Doorkeeper::OAuth::BaseRequest` provides the response handling, scope calculation and token creation, so only the grant-specific parts remain (per RFC 7522 §2.1 the `assertion` parameter carries a single SAML assertion, base64url-encoded without padding):
196
+
197
+ ```ruby
198
+ module SamlBearer
199
+ class TokenRequest < Doorkeeper::OAuth::BaseRequest
200
+ validate :client, error: Doorkeeper::Errors::InvalidClient
201
+ validate :client_supports_grant_flow, error: Doorkeeper::Errors::UnauthorizedClient
202
+ validate :assertion, error: Doorkeeper::Errors::InvalidGrant
203
+ validate :scopes, error: Doorkeeper::Errors::InvalidScope
204
+
205
+ attr_reader :client, :parameters, :access_token
206
+
207
+ def initialize(server, client, parameters = {})
208
+ @server = server
209
+ @client = client
210
+ @parameters = parameters
211
+ @original_scopes = parameters[:scope]
212
+ @grant_type = "urn:ietf:params:oauth:grant-type:saml2-bearer"
213
+ end
214
+
215
+ private
216
+
217
+ def before_successful_response
218
+ find_or_create_access_token(client, resource_owner, scopes, {}, server)
219
+ super
220
+ end
221
+
222
+ def assertion
223
+ # Decode and verify the SAML assertion — signature, audience, validity
224
+ # window, etc. — e.g. with the ruby-saml gem. Skipping verification
225
+ # turns the endpoint into a token vending machine for anyone.
226
+ @assertion ||= decode_and_verify_saml(parameters[:assertion])
227
+ end
228
+
229
+ def resource_owner
230
+ # Map the assertion's subject to a resource owner.
231
+ @resource_owner ||= User.find_by(email: assertion.name_id)
232
+ end
233
+
234
+ def validate_client
235
+ client.present?
236
+ end
237
+
238
+ def validate_client_supports_grant_flow
239
+ Doorkeeper.config.allow_grant_flow_for_client?(grant_type, client&.application)
240
+ end
241
+
242
+ def validate_assertion
243
+ assertion.present? && resource_owner.present?
244
+ end
245
+
246
+ def validate_scopes
247
+ return true if scopes.blank?
248
+
249
+ Doorkeeper::OAuth::Helpers::ScopeChecker.valid?(
250
+ scope_str: scopes.to_s,
251
+ server_scopes: server.scopes,
252
+ app_scopes: client&.scopes,
253
+ grant_type: grant_type,
254
+ )
255
+ end
256
+ end
257
+ end
258
+ ```
259
+
260
+ The `client_supports_grant_flow` validation keeps the custom grant subject to the `allow_grant_flow_for_client` configuration option (per-client grant restrictions), just like the built-in flows.
261
+
262
+ Flows can also handle custom `response_type` values on the authorization endpoint via the `response_type_matches` / `response_type_strategy` options — see the built-in registrations in [`lib/doorkeeper/grant_flow.rb`](lib/doorkeeper/grant_flow.rb) for reference. An extension can also group several flows under one configuration name with `Doorkeeper::GrantFlow.register_alias` (e.g. the OpenID Connect extension registers `implicit_oidc` to expand to multiple response types).
263
+
264
+ ## Custom Client Authentication Methods
265
+
266
+ Doorkeeper authenticates clients (RFC 6749 §2.3) through a registry of named methods. `client_secret_basic`, `client_secret_post` and `none` are built in, and an application or extension can register additional ones — for instance to keep accepting credentials that a partner integration sends in its own headers.
267
+
268
+ A method is any object that responds to `matches_request?` and `authenticate`. Register it before `Doorkeeper.configure` and enable it by listing its registered name in `client_authentication`:
269
+
270
+ ```ruby
271
+ # config/initializers/doorkeeper.rb
272
+ Doorkeeper::ClientAuthentication.register(
273
+ :partner_headers,
274
+ PartnerHeaders::Authentication,
275
+ )
276
+
277
+ Doorkeeper.configure do
278
+ client_authentication %i[client_secret_basic client_secret_post partner_headers none]
279
+ # ...
280
+ end
281
+ ```
282
+
283
+ The order of `client_authentication` is the order the methods are tried in: the first one whose `matches_request?` returns true handles the request.
284
+
285
+ `matches_request?` decides whether the request carries this method's credentials, and `authenticate` extracts them into a `Doorkeeper::ClientAuthentication::Credentials` pair (or `nil`):
286
+
287
+ ```ruby
288
+ module PartnerHeaders
289
+ class Authentication
290
+ def self.matches_request?(request)
291
+ request.get_header("HTTP_X_CLIENT_ID").present? &&
292
+ request.get_header("HTTP_X_CLIENT_SECRET").present?
293
+ end
294
+
295
+ def self.authenticate(request)
296
+ Doorkeeper::ClientAuthentication::Credentials.new(
297
+ request.get_header("HTTP_X_CLIENT_ID"),
298
+ request.get_header("HTTP_X_CLIENT_SECRET"),
299
+ )
300
+ end
301
+ end
302
+ end
303
+ ```
304
+
305
+ Two things are worth keeping in mind when writing one.
306
+
307
+ **Keep `matches_request?` as narrow as possible.** RFC 6749 §2.3 forbids a client from using more than one authentication method in a single request, and Doorkeeper enforces that across the whole registry rather than only the enabled methods. A method that matches too broadly therefore collides with a built-in one and the request is answered with `invalid_request`.
308
+
309
+ **The returned credentials are resolved with `by_uid_and_secret`.** A blank secret resolves only a public (non-confidential) client — that is what the built-in `none` method relies on — while a confidential client is resolved only when the secret matches the registered one. A method that establishes the client's identity by some other proof, such as a client certificate or a signed assertion, therefore still has to produce the registered secret for a confidential client.
310
+
311
+ Enabled methods are advertised in the authorization server metadata, so a registered method appears in `token_endpoint_auth_methods_supported` at `/.well-known/oauth-authorization-server` once `client_authentication` lists it.
312
+
110
313
  ## Example Applications
111
314
 
112
315
  These applications show how Doorkeeper works and how to integrate with it. Start with the oAuth2 server and use the clients to connect with the server.
@@ -5,6 +5,7 @@ module Doorkeeper
5
5
  layout "doorkeeper/admin" unless Doorkeeper.configuration.api_only
6
6
 
7
7
  before_action :authenticate_admin!
8
+ before_action :force_json_format, if: :api_only?
8
9
  before_action :set_application, only: %i[show edit update destroy]
9
10
 
10
11
  def index
@@ -31,11 +32,13 @@ module Doorkeeper
31
32
  @application = Doorkeeper.config.application_model.new(application_params)
32
33
 
33
34
  if @application.save
34
- flash[:notice] = I18n.t(:notice, scope: %i[doorkeeper flash applications create])
35
- flash[:application_secret] = @application.plaintext_secret
36
-
37
35
  respond_to do |format|
38
- format.html { redirect_to oauth_application_url(@application) }
36
+ format.html do
37
+ flash[:notice] = I18n.t(:notice, scope: %i[doorkeeper flash applications create])
38
+ flash[:application_secret] = @application.plaintext_secret
39
+
40
+ redirect_to oauth_application_url(@application)
41
+ end
39
42
  format.json { render json: @application, as_owner: true }
40
43
  end
41
44
  else
@@ -54,10 +57,12 @@ module Doorkeeper
54
57
 
55
58
  def update
56
59
  if @application.update(application_params)
57
- flash[:notice] = I18n.t(:notice, scope: i18n_scope(:update))
58
-
59
60
  respond_to do |format|
60
- format.html { redirect_to oauth_application_url(@application) }
61
+ format.html do
62
+ flash[:notice] = I18n.t(:notice, scope: i18n_scope(:update))
63
+
64
+ redirect_to oauth_application_url(@application)
65
+ end
61
66
  format.json { render json: @application, as_owner: true }
62
67
  end
63
68
  else
@@ -73,10 +78,14 @@ module Doorkeeper
73
78
  end
74
79
 
75
80
  def destroy
76
- flash[:notice] = I18n.t(:notice, scope: i18n_scope(:destroy)) if @application.destroy
81
+ destroyed = @application.destroy
77
82
 
78
83
  respond_to do |format|
79
- format.html { redirect_to oauth_applications_url }
84
+ format.html do
85
+ flash[:notice] = I18n.t(:notice, scope: i18n_scope(:destroy)) if destroyed
86
+
87
+ redirect_to oauth_applications_url
88
+ end
80
89
  format.json { head :no_content }
81
90
  end
82
91
  end
@@ -95,5 +104,25 @@ module Doorkeeper
95
104
  def i18n_scope(action)
96
105
  %i[doorkeeper flash applications] << action
97
106
  end
107
+
108
+ def api_only?
109
+ Doorkeeper.config.api_only
110
+ end
111
+
112
+ # In api_only mode this controller descends from ActionController::API,
113
+ # which has no flash and renders no views — but Rails still negotiates a
114
+ # format from the request, and only a client that names JSON explicitly
115
+ # lands on the json branch. Measured on Rails 8.0: an absent Accept header
116
+ # and a browser-like list such as "application/json, text/plain, */*"
117
+ # (axios's default) both resolve to text/html, while a bare "*/*" (curl,
118
+ # Net::HTTP, python-requests) resolves to Mime::ALL, which respond_to
119
+ # answers with the first registered format — html in every action here.
120
+ # Those requests therefore ran the html branch and raised on flash, after
121
+ # create had already persisted the record and destroy had already deleted
122
+ # it. Nothing needs negotiating when only JSON can be served, so pin the
123
+ # format rather than leaving a branch registered that cannot run.
124
+ def force_json_format
125
+ request.format = :json
126
+ end
98
127
  end
99
128
  end
@@ -65,7 +65,12 @@ module Doorkeeper
65
65
  pre_auth.client,
66
66
  current_resource_owner,
67
67
  pre_auth.scopes,
68
- )
68
+ ) do |token|
69
+ # RFC 8707: a token for a different audience must not satisfy the match.
70
+ Doorkeeper.config.access_token_model.resource_indicators_match?(
71
+ token, pre_auth.resource_indicators&.join(" ").presence,
72
+ )
73
+ end
69
74
  end
70
75
 
71
76
  def redirect_or_render(auth)
@@ -101,11 +106,20 @@ module Doorkeeper
101
106
  end
102
107
 
103
108
  def pre_auth_params
104
- params.slice(*pre_auth_param_fields).permit(*pre_auth_param_fields)
109
+ # RFC 8707: `resource` may appear as a single value (?resource=…) or as
110
+ # a Rails-style array (?resource[]=…&resource[]=…). Both scalar and array
111
+ # forms are permitted through strong parameters.
112
+ #
113
+ # NOTE: The RFC wire format uses repeated keys (?resource=…&resource=…),
114
+ # but Rack collapses those into the last value. Clients targeting this
115
+ # endpoint must use the resource[] bracket syntax for multiple values.
116
+ params
117
+ .slice(*pre_auth_param_fields, :resource)
118
+ .permit(*pre_auth_param_fields, :resource, resource: [])
105
119
  end
106
120
 
107
121
  def pre_auth_param_fields
108
- custom_access_token_attributes + %i[
122
+ @pre_auth_param_fields ||= custom_access_token_attributes + %i[
109
123
  client_id
110
124
  code_challenge
111
125
  code_challenge_method
@@ -123,9 +123,13 @@ module Doorkeeper
123
123
  def revocable_token
124
124
  return @revocable_token if defined? @revocable_token
125
125
 
126
+ # The hint is a lookup-order optimization, not a filter: a client is
127
+ # explicitly allowed to guess it wrong, so when the lookup by the hinted
128
+ # type finds nothing, RFC 7009 §2.1 and RFC 7662 §2.1 require the search
129
+ # to extend across the other supported token type.
126
130
  @revocable_token =
127
131
  if params[:token_type_hint] == "refresh_token"
128
- refresh_token
132
+ refresh_token || access_token
129
133
  else
130
134
  access_token || refresh_token
131
135
  end
@@ -19,6 +19,18 @@
19
19
  </div>
20
20
  <% end %>
21
21
 
22
+ <% if Array(@pre_auth.resource_indicators).any? %>
23
+ <div id="oauth-resources">
24
+ <h3><%= t('.resources') %></h3>
25
+
26
+ <ul class="text-info">
27
+ <% Array(@pre_auth.resource_indicators).each do |resource| %>
28
+ <li><%= resource %></li>
29
+ <% end %>
30
+ </ul>
31
+ </div>
32
+ <% end %>
33
+
22
34
  <div class="actions">
23
35
  <%= form_tag oauth_authorization_path, method: :post do %>
24
36
  <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
@@ -32,6 +44,9 @@
32
44
  <% @pre_auth.custom_access_token_attributes.each do |attribute_name, attribute_value| %>
33
45
  <%= hidden_field_tag attribute_name, attribute_value, id: nil %>
34
46
  <% end %>
47
+ <% Array.wrap(@pre_auth.resource_indicators).each do |resource_value| %>
48
+ <%= hidden_field_tag "resource[]", resource_value, id: nil %>
49
+ <% end %>
35
50
  <%= submit_tag t('doorkeeper.authorizations.buttons.authorize'), class: "btn btn-success btn-lg btn-block" %>
36
51
  <% end %>
37
52
  <%= form_tag oauth_authorization_path, method: :delete do %>
@@ -46,6 +61,9 @@
46
61
  <% @pre_auth.custom_access_token_attributes.each do |attribute_name, attribute_value| %>
47
62
  <%= hidden_field_tag attribute_name, attribute_value, id: nil %>
48
63
  <% end %>
64
+ <% Array.wrap(@pre_auth.resource_indicators).each do |resource_value| %>
65
+ <%= hidden_field_tag "resource[]", resource_value, id: nil %>
66
+ <% end %>
49
67
  <%= submit_tag t('doorkeeper.authorizations.buttons.deny'), class: "btn btn-danger btn-lg btn-block" %>
50
68
  <% end %>
51
69
  </div>
@@ -70,6 +70,7 @@ en:
70
70
  title: 'Authorization required'
71
71
  prompt: 'Authorize %{client_name} to use your account?'
72
72
  able_to: 'This application will be able to:'
73
+ resources: 'Resources'
73
74
  show:
74
75
  title: 'Authorization code:'
75
76
  form_post:
@@ -108,6 +109,7 @@ en:
108
109
  other: 'The code_challenge_method must be one of %{challenge_methods}.'
109
110
  server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.'
110
111
  temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.'
112
+ invalid_target: 'The requested resource is invalid, missing, unknown, or malformed.'
111
113
 
112
114
  # Configuration error messages
113
115
  credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.'
@@ -16,6 +16,12 @@ module Doorkeeper
16
16
  @callable = callable
17
17
  end
18
18
 
19
+ # Legacy extractors pull a uid/secret pair out of the request, so they
20
+ # are treated as shared-secret methods.
21
+ def uses_shared_secret?
22
+ true
23
+ end
24
+
19
25
  def matches_request?(request)
20
26
  credentials_for(request).present?
21
27
  end
@@ -18,6 +18,23 @@ module Doorkeeper
18
18
  @name = name
19
19
  @strategy = strategy
20
20
  end
21
+
22
+ # Whether this method authenticates clients with a shared symmetric
23
+ # secret. Callers that must refuse such methods — because no secret
24
+ # can have been established with the client — can ask the registry
25
+ # instead of keeping a hard-coded list of method names.
26
+ #
27
+ # Strategies may declare this themselves by defining
28
+ # +uses_shared_secret?+; for strategies that don't, the registration
29
+ # name is checked for "client_secret" as a conservative fallback, so an
30
+ # undeclared method errs on the side of being treated as secret-based.
31
+ def uses_shared_secret?
32
+ if strategy.respond_to?(:uses_shared_secret?)
33
+ strategy.uses_shared_secret?
34
+ else
35
+ name.to_s.include?("client_secret")
36
+ end
37
+ end
21
38
  end
22
39
  end
23
40
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doorkeeper
4
+ module ClientAuthentication
5
+ # Credentials for a client that was already fully authenticated by its
6
+ # authentication method (e.g. a verified private_key_jwt assertion), so
7
+ # the client lookup must not run a secret comparison — there is no
8
+ # secret, and the proof of identity has already been checked.
9
+ class VerifiedCredentials < Credentials
10
+ def initialize(uid)
11
+ super(uid, nil)
12
+ end
13
+
14
+ def pre_authenticated?
15
+ true
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "doorkeeper/client_authentication/credentials"
4
+ require "doorkeeper/client_authentication/verified_credentials"
4
5
  require "doorkeeper/client_authentication/fallback_method"
5
6
  require "doorkeeper/client_authentication/legacy_callable"
6
7
  require "doorkeeper/client_authentication/method"
@@ -50,6 +51,14 @@ module Doorkeeper
50
51
  Doorkeeper::OAuth::ClientAuthentication::ClientSecretBasic,
51
52
  )
52
53
 
54
+ # Registered but not part of DEFAULT_METHODS: servers opt in through the
55
+ # +client_authentication+ config option. Requires the "jwt" gem at the
56
+ # moment an assertion is authenticated.
57
+ register(
58
+ :private_key_jwt,
59
+ Doorkeeper::OAuth::ClientAuthentication::PrivateKeyJwt,
60
+ )
61
+
53
62
  # Converts a deprecated +client_credentials+ configuration into the client
54
63
  # authentication method names / adapters understood by the registry.
55
64
  # Unknown values are warned about and dropped; callables are wrapped in a
@@ -385,6 +385,53 @@ module Doorkeeper
385
385
  # (RFC 8414). When nil, the request base URL is used instead.
386
386
  option :issuer, default: nil
387
387
 
388
+ # Resource Indicators for OAuth 2.0 (RFC 8707).
389
+ #
390
+ # When set to a callable (lambda/proc), enables RFC 8707 support. The callable
391
+ # receives the array of resource indicator URIs and the client, and must return
392
+ # true if the resource(s) are acceptable, or false to reject with `invalid_target`.
393
+ #
394
+ # When nil (default), the `resource` parameter is ignored and RFC 8707 is disabled.
395
+ #
396
+ # @example
397
+ # resource_indicator_validator ->(resource_indicators, client) {
398
+ # resource_indicators.all? { |r| allowed_resources.include?(r) }
399
+ # }
400
+ #
401
+ # @param validator [Proc, nil] A callable that validates resource indicators
402
+ # @see https://datatracker.ietf.org/doc/html/rfc8707
403
+ #
404
+ option :resource_indicator_validator, default: nil
405
+
406
+ # Replay guard for `private_key_jwt` client assertions (jti single-use
407
+ # tracking, OIDC Core §9). The default guard remembers jti values in
408
+ # process-local memory, so it cannot see a replay delivered to a
409
+ # different server process; a multi-process deployment can supply a
410
+ # shared store instead — any object answering
411
+ # `first_use?(key, expires_at:)`, returning true when the key was never
412
+ # seen before and remembering it until the unix time `expires_at`.
413
+ #
414
+ # @example
415
+ # private_key_jwt_replay_guard RedisReplayGuard.new
416
+ #
417
+ # @param guard [#first_use?, nil] nil uses the built-in process-local guard
418
+ #
419
+ option :private_key_jwt_replay_guard, default: nil
420
+
421
+ # Cache for JWK Sets fetched from a client's `jwks_uri` during
422
+ # `private_key_jwt` authentication. Defaults to a process-local
423
+ # Doorkeeper::DocumentCache with a 60 second TTL; supply your own
424
+ # instance to change the TTL, or any object answering
425
+ # `fetch(url) { ... }` (returning the cached document or storing and
426
+ # returning the block's result) to share the cache across processes.
427
+ #
428
+ # @example
429
+ # private_key_jwt_jwks_cache Doorkeeper::DocumentCache.new(ttl: 300)
430
+ #
431
+ # @param cache [#fetch, nil] nil uses a built-in process-local cache
432
+ #
433
+ option :private_key_jwt_jwks_cache, default: nil
434
+
388
435
  # Forces the usage of the HTTPS protocol in non-native redirect uris
389
436
  # (enabled by default in non-development environments). OAuth2
390
437
  # delegates security in communication to the HTTPS protocol so it is