Doorkeeper Openid Connect: Dynamic Client Registration feature creates public clients with client_secret
Description
Impact
The DynamicClientRegistrationController#register action hard-codes confidential: false when creating applications (dynamic_client_registration_controller.rb:18-25), yet the response includes a client_secret and advertises token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"].
Because Doorkeeper's Application.by_uid_and_secret treats a blank/missing secret as valid for non-confidential (public) clients, an attacker who knows only the client_id (which is public information) can authenticate as the dynamically-registered client at the token endpoint.
Note that Dynamic Client Registration is opt-in feature which is disabled by default so only projects that explicitly enabled it are affected.
Steps to Reproduce
1. Enable dynamic client registration in the initializer 2. POST /oauth/registration with client_name, redirect_uris, and scope 3. Observe: response returns client_secret, but the created Doorkeeper::Application has confidential: false 4. Call Doorkeeper::Application.by_uid_and_secret(client_id, nil) — it returns the application (credentials bypass) 5. POST /oauth/token with grant_type=client_credentials and only client_id (no client_secret) — the token endpoint issues an access token without any secret verification
Patches
Patched in 1.10.0
Workarounds
Upgrade existing applications created with a Dynamic Client registration to have confidential: true
Affected products
2- Range: >=1.10.0
Patches
7561af83dcf71Determine confidentiality from token_endpoint_auth_method in dynamic client registration
5 files changed · +254 −54
app/controllers/concerns/doorkeeper/openid_connect/token_endpoint_auth_methods_supported_mixin.rb+16 −0 added@@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Doorkeeper + module OpenidConnect + module TokenEndpointAuthMethodsSupportedMixin + CLIENT_CREDENTIALS_METHOD_MAPPING = { + from_basic: 'client_secret_basic', + from_params: 'client_secret_post', + }.freeze + + def token_endpoint_auth_methods_supported(doorkeeper) + doorkeeper.client_credentials_methods.filter_map { |method| CLIENT_CREDENTIALS_METHOD_MAPPING[method] } + end + end + end +end
app/controllers/doorkeeper/openid_connect/discovery_controller.rb+1 −5 modified@@ -5,6 +5,7 @@ module OpenidConnect class DiscoveryController < ::Doorkeeper::ApplicationMetalController include Doorkeeper::Helpers::Controller include GrantTypesSupportedMixin + include TokenEndpointAuthMethodsSupportedMixin WEBFINGER_RELATION = 'http://openid.net/specs/connect/1.0/issuer' @@ -79,11 +80,6 @@ def response_modes_supported(doorkeeper) doorkeeper.authorization_response_flows.flat_map(&:response_mode_matches).uniq end - def token_endpoint_auth_methods_supported(doorkeeper) - mapping = { from_basic: 'client_secret_basic', from_params: 'client_secret_post' } - doorkeeper.client_credentials_methods.filter_map { |method| mapping[method] } - end - def code_challenge_methods_supported(doorkeeper) return unless doorkeeper.access_grant_model.pkce_supported?
app/controllers/doorkeeper/openid_connect/dynamic_client_registration_controller.rb+36 −5 modified@@ -4,8 +4,21 @@ module Doorkeeper module OpenidConnect class DynamicClientRegistrationController < ::Doorkeeper::ApplicationMetalController include GrantTypesSupportedMixin + include TokenEndpointAuthMethodsSupportedMixin + + DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD = 'client_secret_basic' + PUBLIC_CLIENT_AUTH_METHOD = 'none' def register + unless supported_auth_methods.include?(requested_auth_method) + render json: { + error: 'invalid_client_metadata', + error_description: "token_endpoint_auth_method '#{requested_auth_method}' is not supported. " \ + "Supported methods: #{supported_auth_methods.join(', ')}", + }, status: :bad_request + return + end + client = Doorkeeper::Application.create!(application_params) render json: registration_response(client), status: :created rescue ActiveRecord::RecordInvalid => e @@ -20,24 +33,42 @@ def application_params name: params.dig(:client_name), redirect_uri: params.dig(:redirect_uris) || [], scopes: params.dig(:scope), - confidential: false, + confidential: confidential_client?, } end + def requested_auth_method + params[:token_endpoint_auth_method].presence || DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD + end + + def confidential_client? + requested_auth_method != PUBLIC_CLIENT_AUTH_METHOD + end + + def supported_auth_methods + token_endpoint_auth_methods_supported(::Doorkeeper.configuration) + [PUBLIC_CLIENT_AUTH_METHOD] + end + def registration_response(doorkeeper_application) doorkeeper_config = ::Doorkeeper.configuration - { - client_secret: doorkeeper_application.plaintext_secret || doorkeeper_application.secret, + response = { client_id: doorkeeper_application.uid, client_id_issued_at: doorkeeper_application.created_at.to_i, redirect_uris: doorkeeper_application.redirect_uri.split, - token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post], + token_endpoint_auth_method: requested_auth_method, + token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported(doorkeeper_config), response_types: doorkeeper_config.authorization_response_types, grant_types: grant_types_supported(doorkeeper_config), scope: doorkeeper_application.scopes.to_s, - application_type: "web" + application_type: 'web', } + + if confidential_client? + response[:client_secret] = doorkeeper_application.plaintext_secret || doorkeeper_application.secret + end + + response end end end
CHANGELOG.md+1 −0 modified@@ -11,6 +11,7 @@ - [#258] Skip `IdToken` construction on password grants without the `openid` scope - [#259] Skip `IdToken` construction on authorization code grants without the `openid` scope - [#261] Fix obsolete RuboCop configuration (`require:` → `plugins:`, `RSpec/FilePath` split, remove `Capybara/FeatureMethods`) +- [#263] **Security/Breaking:** Determine dynamically registered client's `confidential` flag from `token_endpoint_auth_method` per RFC 7591 — previously every dynamically registered client was created as public (`confidential: false`), which let callers authenticate with only `client_id` (`by_uid_and_secret(uid, nil)` bypass). Default is now `client_secret_basic` (confidential); `none` produces a public client; unsupported values (e.g. `private_key_jwt`) are rejected with `invalid_client_metadata`. Also derive `token_endpoint_auth_methods_supported` in the response from `Doorkeeper.configuration.client_credentials_methods` instead of a hardcoded list, matching #236 ## v1.9.0 (2026-03-16)
spec/controllers/dynamic_client_registration_controller_spec.rb+200 −44 modified@@ -3,6 +3,13 @@ require 'rails_helper' describe Doorkeeper::OpenidConnect::DynamicClientRegistrationController, type: :controller do + let(:redirect_uris) do + [ + 'https://test.host/registration_success', + 'https://test.host/registration_success_second_location', + ] + end + before do Doorkeeper::OpenidConnect.configure do issuer "dummy" @@ -13,50 +20,199 @@ end describe "#register" do - it "creates a Doorkeeper::Application" do - redirect_uris = [ - 'https://test.host/registration_success', - 'https://test.host/registration_success_second_location', - ] - - post :register, params: { - client_name: "dummy_client", - redirect_uris: redirect_uris, - scope: "public" - } - - expect(response.status).to eq 201 - expect(Doorkeeper::Application.count).to eq(1) - - doorkeeper_application = Doorkeeper::Application.first - expect(JSON.parse(response.body)).to eq({ - 'client_secret' => doorkeeper_application.plaintext_secret || doorkeeper_application.secret, - 'client_id' => doorkeeper_application.uid, - 'client_id_issued_at' => doorkeeper_application.created_at.to_i, - 'redirect_uris' => redirect_uris, - 'token_endpoint_auth_methods_supported' => %w[client_secret_basic client_secret_post], - 'response_types' => ['code', 'token', 'id_token', 'id_token token'], - 'grant_types' => %w[authorization_code client_credentials implicit_oidc], - 'scope' => "public", - 'application_type' => "web" - }) - end - - it "errors and returns errors" do - post :register, params: { - client_name: "dummy_client", - redirect_uris: [ - 'http://test.host/registration_success', - ], - scopes: "openid" - } - - expect(response.status).to eq 400 - expect(Doorkeeper::Application.count).to eq(0) - expect(JSON.parse(response.body)).to eq({ - "error" => "invalid_client_params", - "error_description" => "Redirect URI must be an HTTPS/SSL URI." - }) + context "when token_endpoint_auth_method is omitted" do + it "defaults to client_secret_basic and creates a confidential client with a secret" do + post :register, params: { + client_name: "dummy_client", + redirect_uris: redirect_uris, + scope: "public" + } + + expect(response.status).to eq 201 + expect(Doorkeeper::Application.count).to eq(1) + + doorkeeper_application = Doorkeeper::Application.first + expect(doorkeeper_application.confidential).to be true + + body = JSON.parse(response.body) + expect(body).to eq({ + 'client_secret' => doorkeeper_application.plaintext_secret || doorkeeper_application.secret, + 'client_id' => doorkeeper_application.uid, + 'client_id_issued_at' => doorkeeper_application.created_at.to_i, + 'redirect_uris' => redirect_uris, + 'token_endpoint_auth_method' => 'client_secret_basic', + 'token_endpoint_auth_methods_supported' => %w[client_secret_basic client_secret_post], + 'response_types' => ['code', 'token', 'id_token', 'id_token token'], + 'grant_types' => %w[authorization_code client_credentials implicit_oidc], + 'scope' => "public", + 'application_type' => "web", + }) + end + end + + context "when token_endpoint_auth_method is client_secret_basic" do + it "creates a confidential client with a secret" do + post :register, params: { + client_name: "basic_client", + redirect_uris: redirect_uris, + scope: "public", + token_endpoint_auth_method: "client_secret_basic", + } + + expect(response.status).to eq 201 + + doorkeeper_application = Doorkeeper::Application.first + expect(doorkeeper_application.confidential).to be true + + body = JSON.parse(response.body) + expect(body['token_endpoint_auth_method']).to eq('client_secret_basic') + expect(body['client_secret']).to be_present + end + end + + context "when token_endpoint_auth_method is client_secret_post" do + it "creates a confidential client with a secret" do + post :register, params: { + client_name: "post_client", + redirect_uris: redirect_uris, + scope: "public", + token_endpoint_auth_method: "client_secret_post", + } + + expect(response.status).to eq 201 + + doorkeeper_application = Doorkeeper::Application.first + expect(doorkeeper_application.confidential).to be true + + body = JSON.parse(response.body) + expect(body['token_endpoint_auth_method']).to eq('client_secret_post') + expect(body['client_secret']).to be_present + end + end + + context "when token_endpoint_auth_method is none" do + it "creates a public client and omits client_secret from the response" do + post :register, params: { + client_name: "public_client", + redirect_uris: redirect_uris, + scope: "public", + token_endpoint_auth_method: "none", + } + + expect(response.status).to eq 201 + + doorkeeper_application = Doorkeeper::Application.first + expect(doorkeeper_application.confidential).to be false + + body = JSON.parse(response.body) + expect(body['token_endpoint_auth_method']).to eq('none') + expect(body).not_to have_key('client_secret') + end + end + + context "when token_endpoint_auth_method is private_key_jwt" do + it "rejects the request with invalid_client_metadata" do + post :register, params: { + client_name: "jwt_client", + redirect_uris: redirect_uris, + scope: "public", + token_endpoint_auth_method: "private_key_jwt", + } + + expect(response.status).to eq 400 + expect(Doorkeeper::Application.count).to eq(0) + + body = JSON.parse(response.body) + expect(body['error']).to eq('invalid_client_metadata') + expect(body['error_description']).to include('private_key_jwt') + end + end + + context "when token_endpoint_auth_method is an unknown value" do + it "rejects the request with invalid_client_metadata" do + post :register, params: { + client_name: "weird_client", + redirect_uris: redirect_uris, + scope: "public", + token_endpoint_auth_method: "unknown_value", + } + + expect(response.status).to eq 400 + expect(Doorkeeper::Application.count).to eq(0) + + body = JSON.parse(response.body) + expect(body['error']).to eq('invalid_client_metadata') + expect(body['error_description']).to include('unknown_value') + end + end + + context "token_endpoint_auth_methods_supported in the response" do + it "matches the server's configured client_credentials methods" do + Doorkeeper.configure do + orm :active_record + client_credentials :from_basic + end + + post :register, params: { + client_name: "cfg_client", + redirect_uris: redirect_uris, + scope: "public", + } + + expect(response.status).to eq 201 + body = JSON.parse(response.body) + expect(body['token_endpoint_auth_methods_supported']).to eq(%w[client_secret_basic]) + end + end + + context "security regression: confidential client cannot bypass credentials" do + it "is not returned by by_uid_and_secret(uid, nil)" do + post :register, params: { + client_name: "secure_client", + redirect_uris: redirect_uris, + scope: "public", + } + + expect(response.status).to eq 201 + doorkeeper_application = Doorkeeper::Application.first + expect(doorkeeper_application.confidential).to be true + + expect(Doorkeeper::Application.by_uid_and_secret(doorkeeper_application.uid, nil)).to be_nil + end + + it "is returned by by_uid_and_secret(uid, nil) when token_endpoint_auth_method is none" do + post :register, params: { + client_name: "intentionally_public", + redirect_uris: redirect_uris, + scope: "public", + token_endpoint_auth_method: "none", + } + + expect(response.status).to eq 201 + doorkeeper_application = Doorkeeper::Application.first + expect(doorkeeper_application.confidential).to be false + + expect(Doorkeeper::Application.by_uid_and_secret(doorkeeper_application.uid, nil)).to eq(doorkeeper_application) + end + end + + context "with invalid redirect_uris" do + it "errors and returns errors" do + post :register, params: { + client_name: "dummy_client", + redirect_uris: [ + 'http://test.host/registration_success', + ], + scope: "openid" + } + + expect(response.status).to eq 400 + expect(Doorkeeper::Application.count).to eq(0) + expect(JSON.parse(response.body)).to eq({ + "error" => "invalid_client_params", + "error_description" => "Redirect URI must be an HTTPS/SSL URI." + }) + end end end end
20048d906dc1208e1211f00822b55df75f70Extract DCR validation into a dedicated class with Doorkeeper::Validations DSL
7 files changed · +227 −37
app/controllers/doorkeeper/openid_connect/dynamic_client_registration_controller.rb+15 −37 modified@@ -3,68 +3,46 @@ module Doorkeeper module OpenidConnect class DynamicClientRegistrationController < ::Doorkeeper::ApplicationMetalController - include GrantTypesSupportedMixin - include TokenEndpointAuthMethodsSupportedMixin - - DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_basic" - PUBLIC_CLIENT_AUTH_METHOD = "none" - def register - unless supported_auth_methods.include?(requested_auth_method) - render json: { - error: "invalid_client_metadata", - error_description: "token_endpoint_auth_method '#{requested_auth_method}' is not supported. " \ - "Supported methods: #{supported_auth_methods.join(", ")}", - }, status: :bad_request + registration = OAuth::DynamicRegistrationRequest.new(::Doorkeeper.configuration, params) + + unless registration.valid? + render json: registration.error_response, status: :bad_request return end - client = Doorkeeper::Application.create!(application_params) - render json: registration_response(client), status: :created + client = Doorkeeper::Application.create!(application_params(registration)) + render json: registration_response(client, registration), status: :created rescue ActiveRecord::RecordInvalid => e render json: { error: "invalid_client_params", error_description: e.record.errors.full_messages.join(", ") }, status: :bad_request end private - def application_params + def application_params(registration) { name: params.dig(:client_name), redirect_uri: params.dig(:redirect_uris) || [], scopes: params.dig(:scope), - confidential: confidential_client?, + confidential: registration.confidential_client?, } end - def requested_auth_method - params[:token_endpoint_auth_method].presence || DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD - end - - def confidential_client? - requested_auth_method != PUBLIC_CLIENT_AUTH_METHOD - end - - def supported_auth_methods - token_endpoint_auth_methods_supported + [PUBLIC_CLIENT_AUTH_METHOD] - end - - def registration_response(doorkeeper_application) - doorkeeper_config = ::Doorkeeper.configuration - + def registration_response(doorkeeper_application, registration) response = { client_id: doorkeeper_application.uid, client_id_issued_at: doorkeeper_application.created_at.to_i, redirect_uris: doorkeeper_application.redirect_uri.split, - token_endpoint_auth_method: requested_auth_method, - token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported, - response_types: doorkeeper_config.authorization_response_types, - grant_types: grant_types_supported(doorkeeper_config), + token_endpoint_auth_method: registration.token_endpoint_auth_method, + token_endpoint_auth_methods_supported: registration.token_endpoint_auth_methods_supported, + response_types: registration.requested_response_types, + grant_types: registration.requested_grant_types, scope: doorkeeper_application.scopes.to_s, - application_type: "web", + application_type: registration.requested_application_type, } - if confidential_client? + if registration.confidential_client? response[:client_secret] = doorkeeper_application.plaintext_secret || doorkeeper_application.secret end
CHANGELOG.md+1 −0 modified@@ -14,6 +14,7 @@ - [#263] **Security/Breaking:** Determine dynamically registered client's `confidential` flag from `token_endpoint_auth_method` per RFC 7591 — previously every dynamically registered client was created as public (`confidential: false`), which let callers authenticate with only `client_id` (`by_uid_and_secret(uid, nil)` bypass). Default is now `client_secret_basic` (confidential); `none` produces a public client; unsupported values (e.g. `private_key_jwt`) are rejected with `invalid_client_metadata`. Also derive `token_endpoint_auth_methods_supported` in the response from `Doorkeeper.configuration.client_credentials_methods` instead of a hardcoded list, matching #236 - [#264] Apply safe RuboCop autocorrections and fix resulting artifacts - [#265] Add Dynamic Client Registration section to README +- [#266] Validate `application_type`, `response_types`, and `grant_types` parameters in dynamic client registration per RFC 7591 — reject unsupported values with `invalid_client_metadata` and echo the requested values back in the registration response, instead of silently ignoring them and returning the server's global configuration ## v1.9.0 (2026-03-16)
lib/doorkeeper/openid_connect/grant_types_supported_mixin.rb+0 −0 renamedlib/doorkeeper/openid_connect/oauth/dynamic_registration_request.rb+108 −0 added@@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Doorkeeper + module OpenidConnect + module OAuth + class DynamicRegistrationRequest + include Doorkeeper::Validations + include Doorkeeper::OpenidConnect::TokenEndpointAuthMethodsSupportedMixin + include Doorkeeper::OpenidConnect::GrantTypesSupportedMixin + + DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_basic" + PUBLIC_CLIENT_AUTH_METHOD = "none" + DEFAULT_APPLICATION_TYPE = "web" + SUPPORTED_APPLICATION_TYPES = %w[web native].freeze + + validate :token_endpoint_auth_method, error: :invalid_client_metadata + validate :application_type, error: :invalid_client_metadata + validate :response_types, error: :invalid_client_metadata + validate :grant_types, error: :invalid_client_metadata + + def initialize(server, params) + @server = server + @params = params + end + + def token_endpoint_auth_method + @params[:token_endpoint_auth_method].presence || DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD + end + + def requested_application_type + @params[:application_type].presence || DEFAULT_APPLICATION_TYPE + end + + def requested_response_types + types = Array(@params[:response_types]).compact_blank + types.presence || server_response_types + end + + def requested_grant_types + types = Array(@params[:grant_types]).compact_blank + types.presence || server_grant_types + end + + def confidential_client? + token_endpoint_auth_method != PUBLIC_CLIENT_AUTH_METHOD + end + + def error_response + { error: error.to_s, error_description: @error_description } + end + + private + + attr_reader :server + + def validate_token_endpoint_auth_method + return true if supported_auth_methods.include?(token_endpoint_auth_method) + + @error_description = + "token_endpoint_auth_method '#{token_endpoint_auth_method}' is not supported. " \ + "Supported methods: #{supported_auth_methods.join(", ")}" + false + end + + def validate_application_type + return true if SUPPORTED_APPLICATION_TYPES.include?(requested_application_type) + + @error_description = + "application_type '#{requested_application_type}' is not supported. " \ + "Supported types: #{SUPPORTED_APPLICATION_TYPES.join(", ")}" + false + end + + def validate_response_types + unsupported = requested_response_types - server_response_types + return true if unsupported.empty? + + @error_description = + "response_types #{unsupported.join(", ")} are not supported. " \ + "Supported types: #{server_response_types.join(", ")}" + false + end + + def validate_grant_types + unsupported = requested_grant_types - server_grant_types + return true if unsupported.empty? + + @error_description = + "grant_types #{unsupported.join(", ")} are not supported. " \ + "Supported types: #{server_grant_types.join(", ")}" + false + end + + def server_response_types + server.authorization_response_types + end + + def server_grant_types + grant_types_supported(server) + end + + def supported_auth_methods + token_endpoint_auth_methods_supported + [PUBLIC_CLIENT_AUTH_METHOD] + end + end + end + end +end
lib/doorkeeper/openid_connect.rb+4 −0 modified@@ -25,8 +25,12 @@ require "doorkeeper/openid_connect/helpers/controller" +require "doorkeeper/openid_connect/grant_types_supported_mixin" +require "doorkeeper/openid_connect/token_endpoint_auth_methods_supported_mixin" + require "doorkeeper/openid_connect/oauth/authorization/code" require "doorkeeper/openid_connect/oauth/authorization_code_request" +require "doorkeeper/openid_connect/oauth/dynamic_registration_request" require "doorkeeper/openid_connect/oauth/password_access_token_request" require "doorkeeper/openid_connect/oauth/pre_authorization" require "doorkeeper/openid_connect/oauth/token_response"
lib/doorkeeper/openid_connect/token_endpoint_auth_methods_supported_mixin.rb+0 −0 renamedspec/controllers/dynamic_client_registration_controller_spec.rb+99 −0 modified@@ -196,6 +196,105 @@ end end + context "when application_type is native" do + it "echoes application_type back in the response" do + post :register, params: { + client_name: "native_client", + redirect_uris: redirect_uris, + scope: "public", + application_type: "native", + } + + expect(response.status).to eq 201 + body = JSON.parse(response.body) + expect(body["application_type"]).to eq("native") + end + end + + context "when application_type is not supported" do + it "rejects the request with invalid_client_metadata" do + post :register, params: { + client_name: "weird_client", + redirect_uris: redirect_uris, + scope: "public", + application_type: "service", + } + + expect(response.status).to eq 400 + expect(Doorkeeper::Application.count).to eq(0) + + body = JSON.parse(response.body) + expect(body["error"]).to eq("invalid_client_metadata") + expect(body["error_description"]).to include("application_type 'service'") + end + end + + context "when response_types is a subset of the server's supported types" do + it "echoes the requested response_types back in the response" do + post :register, params: { + client_name: "code_only_client", + redirect_uris: redirect_uris, + scope: "public", + response_types: ["code"], + } + + expect(response.status).to eq 201 + body = JSON.parse(response.body) + expect(body["response_types"]).to eq(["code"]) + end + end + + context "when response_types contains an unsupported value" do + it "rejects the request with invalid_client_metadata" do + post :register, params: { + client_name: "bad_response_type_client", + redirect_uris: redirect_uris, + scope: "public", + response_types: %w[code unsupported_type], + } + + expect(response.status).to eq 400 + expect(Doorkeeper::Application.count).to eq(0) + + body = JSON.parse(response.body) + expect(body["error"]).to eq("invalid_client_metadata") + expect(body["error_description"]).to include("unsupported_type") + end + end + + context "when grant_types is a subset of the server's supported types" do + it "echoes the requested grant_types back in the response" do + post :register, params: { + client_name: "code_grant_client", + redirect_uris: redirect_uris, + scope: "public", + grant_types: ["authorization_code"], + } + + expect(response.status).to eq 201 + body = JSON.parse(response.body) + expect(body["grant_types"]).to eq(["authorization_code"]) + end + end + + context "when grant_types contains an unsupported value" do + it "rejects the request with invalid_client_metadata" do + post :register, params: { + client_name: "bad_grant_client", + redirect_uris: redirect_uris, + scope: "public", + grant_types: %w[authorization_code password], + } + + expect(response.status).to eq 400 + expect(Doorkeeper::Application.count).to eq(0) + + body = JSON.parse(response.body) + expect(body["error"]).to eq("invalid_client_metadata") + expect(body["error_description"]).to include("password") + end + end + context "with invalid redirect_uris" do it "errors and returns errors" do post :register, params: {
2754bbb5a0ccTreat auth_time_from_resource_owner as optional in IdToken
3 files changed · +26 −1
CHANGELOG.md+2 −1 modified@@ -3,9 +3,10 @@ - Please add here - [#241] Fix NameError on doorkeeper master by deferring AR model loading in run_hooks (see [Doorkeeper PR](https://github.com/doorkeeper-gem/doorkeeper/pull/1804)) - [#246] Fix `at_hash` to use correct hash algorithm based on `signing_algorithm` -* [#250] Return configured `issuer` instead of `root_url` in WebFinger response (thanks to @sato11 for the original work in #172) +- [#250] Return configured `issuer` instead of `root_url` in WebFinger response (thanks to @sato11 for the original work in #172) - [#248] Fix `max_age` always triggering reauthentication when `auth_time_from_resource_owner` returns Integer - [#254] **Breaking:** Omit `expires_in` from the `response_type=id_token` response (OIDC Core §3.2.2.5 — `expires_in` represents the Access Token lifetime; it is still returned for `response_type=id_token token`) +- [#252] Treat `auth_time_from_resource_owner` as optional in `IdToken` — omit `auth_time` claim when unconfigured instead of raising `InvalidConfiguration` ## v1.9.0 (2026-03-16)
lib/doorkeeper/openid_connect/id_token.rb+2 −0 modified@@ -74,6 +74,8 @@ def issued_at def auth_time Doorkeeper::OpenidConnect.configuration.auth_time_from_resource_owner.call(@resource_owner).try(:to_i) + rescue Errors::InvalidConfiguration + nil end end end
spec/lib/id_token_spec.rb+22 −0 modified@@ -100,6 +100,28 @@ expect(claims[:iss]).to eq "#{user.id}-#{access_token.application.uid}" end end + + context 'when auth_time_from_resource_owner is not configured' do + before do + Doorkeeper::OpenidConnect.configure do + issuer 'dummy' + + resource_owner_from_access_token do |access_token| + User.find_by(id: access_token.resource_owner_id) + end + + subject do |resource_owner| + resource_owner.id + end + end + end + + it 'builds claims without raising and omits auth_time' do + expect { subject.claims }.not_to raise_error + expect(subject.claims[:auth_time]).to be_nil + expect(subject.as_json).not_to include(:auth_time) + end + end end describe '#as_json' do
2071176e6122207a4c5b003cVulnerability mechanics
Root cause
"The Dynamic Client Registration feature incorrectly creates public clients with a client secret."
Attack vector
An attacker must know the client_id of a dynamically registered application. The attacker can then authenticate to the token endpoint using only the client_id, as the system treats a missing client secret as valid for public clients. This allows the attacker to obtain an access token without providing the actual client secret [ref_id=1].
Affected code
The vulnerability lies within the `DynamicClientRegistrationController#register` action, specifically in the code responsible for creating new applications. This action hard-codes `confidential: false` when creating applications, even when a client secret is generated, as indicated in `dynamic_client_registration_controller.rb:18-25` [ref_id=1].
What the fix does
The patch ensures that when dynamic client registration creates an application, it correctly sets the `confidential` attribute to `true` if a client secret is generated. This aligns the application's confidentiality status with the presence of a secret, preventing public clients from being issued secrets and thus closing the authentication bypass vulnerability [ref_id=1].
Preconditions
- configDynamic Client Registration must be enabled in the initializer.
- inputThe attacker must know the client_id of a dynamically registered application.
Reproduction
1. Enable dynamic client registration in the initializer. 2. POST /oauth/registration with client_name, redirect_uris, and scope. 3. Observe: response returns client_secret, but the created Doorkeeper::Application has confidential: false. 4. Call `Doorkeeper::Application.by_uid_and_secret(client_id, nil)` — it returns the application (credentials bypass). 5. POST /oauth/token with grant_type=client_credentials and only client_id (no client_secret) — the token endpoint issues an access token without any secret verification [ref_id=1].
Generated on Jun 4, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.