Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/controllers/doorkeeper/discovery_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module Doorkeeper
class DiscoveryController < Doorkeeper::ApplicationMetalController
def show
headers.merge!(discovery_response.headers)
render json: discovery_response.body,
status: discovery_response.status
end

private

def discovery_response
@discovery_response ||= Doorkeeper::OAuth::DiscoveryResponse.new(
root_url,
-> (**args) { url_for(**args) }
Comment on lines +15 to +16

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not particularly keen on this, but including UrlFor or similar in the Doorkeeper::OAuth::DiscoveryResponse class didn't work as I'd expect it to, and I need the url builder in order to generate the absolute URLs for the authorization server metadata.

)
end
end
end
1 change: 1 addition & 0 deletions lib/doorkeeper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ module OAuth
autoload :Client, "doorkeeper/oauth/client"
autoload :ClientCredentialsRequest, "doorkeeper/oauth/client_credentials_request"
autoload :CodeRequest, "doorkeeper/oauth/code_request"
autoload :DiscoveryResponse, "doorkeeper/oauth/discovery_response"
autoload :ErrorResponse, "doorkeeper/oauth/error_response"
autoload :Error, "doorkeeper/oauth/error"
autoload :InvalidTokenResponse, "doorkeeper/oauth/invalid_token_response"
Expand Down
7 changes: 7 additions & 0 deletions lib/doorkeeper/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ def configure_secrets_for(type, using:, fallback:)
#
option :realm, default: "Doorkeeper"

# Issuer URL for the OAuth Authorization Server
option :issuer, default: nil
Comment on lines +324 to +325

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically this is maybe OpenID more, but OAuth 2.0 does have a spec for it, see: #1720


# Forces the usage of the HTTPS protocol in non-native redirect uris
# (enabled by default in non-development environments). OAuth2
# delegates security in communication to the HTTPS protocol so it is
Expand Down Expand Up @@ -397,6 +400,10 @@ def configure_secrets_for(type, using:, fallback:)
option :application_class,
default: "Doorkeeper::Application"

# Allows setting a hash of custom data on the OAuth 2.0 Authorization
# Server Metadata discovery response.
option :custom_discovery_data, default: {}
Comment on lines +403 to +405

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intended for applications to use, e.g., Mastodon where we want to set service_documentation and app_registration_url properties in the metadata.


# Allows to set blank redirect URIs for Applications in case
# server configured to use URI-less grant flows.
#
Expand Down
12 changes: 12 additions & 0 deletions lib/doorkeeper/config/validations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def validate!
validate_token_reuse_limit
validate_secret_strategies
validate_pkce_code_challenge_methods
validate_custom_discovery_data
end

private
Expand Down Expand Up @@ -60,6 +61,17 @@ def validate_pkce_code_challenge_methods

@pkce_code_challenge_methods = ['plain', 'S256']
end

def validate_custom_discovery_data
return if custom_discovery_data.is_a? Hash

::Rails.logger.warn(
"[DOORKEEPER] You have configured an invalid value for custom_discovery_data option. " \
"It must be a Hash, and will be overridden with an empty hash.",
)

@custom_discovery_data = {}
end
end
end
end
115 changes: 115 additions & 0 deletions lib/doorkeeper/oauth/discovery_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# frozen_string_literal: true

module Doorkeeper
module OAuth
class DiscoveryResponse < BaseResponse
def initialize(root_url, url_builder)
@root_url = root_url
@url_builder = url_builder
end

def body
@body ||= {
issuer: issuer || @root_url,
authorization_endpoint: authorization_endpoint,
token_endpoint: token_endpoint,
revocation_endpoint: revocation_endpoint,
userinfo_endpoint: userinfo_endpoint,
scopes_supported: scopes_supported,
response_types_supported: response_types_supported,
response_modes_supported: response_modes_supported,
grant_types_supported: grant_types_supported,
token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported,
code_challenge_methods_supported: code_challenge_methods_supported,
}.merge(custom_discovery_data)
end

def status
:ok
end

def headers
{
"Cache-Control" => "public",
"Content-Type" => "application/json; charset=utf-8",
}
end

private

def config
@config ||= Doorkeeper.configuration
end

def url_for(**args)
@url_builder.call(**args)
end

def custom_discovery_data
config.custom_discovery_data.symbolize_keys
end

def issuer
config.issuer
end

def authorization_endpoint
mapping = Doorkeeper::Rails::Routes.mapping[:authorizations] || {}

url_for(
controller: mapping[:controllers] || "doorkeeper/authorizations",
action: 'new'
)
end

def token_endpoint
mapping = Doorkeeper::Rails::Routes.mapping[:tokens] || {}

url_for(
controller: mapping[:controllers] || "doorkeeper/tokens",
action: 'create'
)
end

def userinfo_endpoint
nil
end

def revocation_endpoint
mapping = Doorkeeper::Rails::Routes.mapping[:tokens] || {}

url_for(
controller: mapping[:controllers] || "doorkeeper/tokens",
action: 'revoke'
)
end

def scopes_supported
config.scopes.to_a

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be better as:

Suggested change
config.scopes.to_a
config.scopes.map(&:to_s)

Might depend on how Scopes implements to_a

end

def response_types_supported
config.authorization_response_types
end

def response_modes_supported
config.authorization_response_flows.flat_map(&:response_mode_matches).uniq
end

def grant_types_supported
grant_types_supported = config.grant_flows.dup
grant_types_supported << 'refresh_token' if !!config.refresh_token_enabled?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically broken, because use_refresh_token configuration can be a block/proc, which accepts server and context which, well, I don't think we have here, nor would it be possible to have?

@ThisIsMissEm ThisIsMissEm Apr 20, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this more, if we did drastically change how refresh tokens work, as suggested in #1771, then we could have options like:

Doorkeeper.configure do
  orm :active_record

  # Enable using them in general
  use_refresh_tokens true

  # Set the expiry for refresh tokens:
  refresh_token_expires_in 30.days

  # Allow refresh tokens to only be used by specific clients:
  allow_refresh_tokens_for do |client, access_grant|
    access_grant.scopes.exists?('offline_access')
  end
end

Or something.

grant_types_supported
end

# FIXME: https://github.com/doorkeeper-gem/doorkeeper/pull/1770
def token_endpoint_auth_methods_supported
%w(none client_secret_basic client_secret_post)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be fixed by #1772

end

def code_challenge_methods_supported
config.pkce_code_challenge_methods_supported
end
end
end
end
6 changes: 6 additions & 0 deletions lib/doorkeeper/rails/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,16 @@ def generate_routes!(options)
map_route(:authorized_applications, :authorized_applications_routes)
map_route(:token_info, :token_info_routes)
end

map_route(:discovery, :discovery_routes)
end

private

def discovery_routes(mapping)
routes.get ".well-known/oauth-authorization-server", controller: mapping[:controllers], action: :show
end

def authorization_routes(mapping)
routes.resource(
:authorization,
Expand Down
1 change: 1 addition & 0 deletions lib/doorkeeper/rails/routes/mapping.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def initialize
authorizations: "doorkeeper/authorizations",
applications: "doorkeeper/applications",
authorized_applications: "doorkeeper/authorized_applications",
discovery: "doorkeeper/discovery",
tokens: "doorkeeper/tokens",
token_info: "doorkeeper/token_info",
}
Expand Down
7 changes: 7 additions & 0 deletions spec/dummy/app/controllers/custom_discovery_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class CustomDiscoveryController < ::ApplicationController
def show
render nothing: true
end
end
80 changes: 80 additions & 0 deletions spec/requests/endpoints/discovery_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe "Discovery endpoint" do
before do
default_scopes_exist :read
optional_scopes_exist :write, :publish
end

it "returns json" do
get "/.well-known/oauth-authorization-server"

response_status_should_be(200)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a content-type assertion here as well.


expect(json_response).to have_key("issuer")
expect(json_response).to have_key("authorization_endpoint")
expect(json_response).to have_key("token_endpoint")
expect(json_response).to have_key("revocation_endpoint")
expect(json_response).to have_key("userinfo_endpoint")
expect(json_response).to have_key("scopes_supported")
expect(json_response).to have_key("response_types_supported")
expect(json_response).to have_key("response_modes_supported")
expect(json_response).to have_key("grant_types_supported")
expect(json_response).to have_key("token_endpoint_auth_methods_supported")
expect(json_response).to have_key("code_challenge_methods_supported")

expect(json_response["issuer"]).to be_a(String)

expect(json_response["scopes_supported"]).to be_a(Array)
expect(json_response["response_types_supported"]).to be_a(Array)
expect(json_response["response_modes_supported"]).to be_a(Array)
expect(json_response["grant_types_supported"]).to be_a(Array)
expect(json_response["token_endpoint_auth_methods_supported"]).to be_a(Array)
expect(json_response["code_challenge_methods_supported"]).to be_a(Array)
Comment on lines +16 to +35

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I should really test logic of the discovery response here, so instead I'm just asserting properties and types for things that must be arrays.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very similar to what we did for a one-off in our application:

      get oauth_discovery_path

      expect(response).to have_http_status(200)

      expect(response.content_type).to eq("application/json; charset=utf-8")

      object = JSON.parse(response.body)

      expect(object.dig("authorization_endpoint")).to eq("http://www.example.com/oauth/authorize")
      expect(object.dig("token_endpoint")).to eq("http://www.example.com/oauth/token")
      expect(object.dig("revocation_endpoint")).to eq("http://www.example.com/oauth/revoke")
      ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, similar to the Mastodon one as well: https://github.com/mastodon/mastodon/blob/main/spec/requests/well_known/oauth_metadata_spec.rb

I'm just not sure I want to be validating the values or not here, since the paths can be configured with doorkeeper.

end

context 'With custom issuer' do
before do
config_is_set(:issuer, "http://example.test/")
end

it "returns json" do
get "/.well-known/oauth-authorization-server"

response_status_should_be(200)
expect(json_response["issuer"]).to eq "http://example.test/"
end
end

context 'With code challenge methods' do
before do
config_is_set(:pkce_code_challenge_methods, ["S256"])
end

it "returns json" do
get "/.well-known/oauth-authorization-server"

response_status_should_be(200)
expect(json_response["code_challenge_methods_supported"]).to eq(["S256"])
end
end

context 'With custom discovery attributes' do
before do
config_is_set(:custom_discovery_data, {
userinfo_endpoint: '/userinfo',
app_registration_url: 'app_registration_url.example'
})
end

it "returns json" do
get "/.well-known/oauth-authorization-server"

response_status_should_be(200)
expect(json_response["userinfo_endpoint"]).to eq("/userinfo")
expect(json_response["app_registration_url"]).to eq("app_registration_url.example")
end
end
end
22 changes: 19 additions & 3 deletions spec/routing/custom_controller_routes_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
controllers authorizations: "custom_authorizations",
tokens: "custom_authorizations",
applications: "custom_authorizations",
token_info: "custom_authorizations"
token_info: "custom_authorizations",
discovery: "custom_discovery"

as authorizations: "custom_auth",
tokens: "custom_token",
Expand All @@ -29,7 +30,8 @@
controllers authorizations: "custom_authorizations",
tokens: "custom_authorizations",
applications: "custom_authorizations",
token_info: "custom_authorizations"
token_info: "custom_authorizations",
discovery: "custom_discovery"

as authorizations: "custom_auth",
tokens: "custom_token",
Expand All @@ -41,7 +43,8 @@
use_doorkeeper do
controllers authorizations: "custom_authorizations",
tokens: "custom_authorizations",
token_info: "custom_authorizations"
token_info: "custom_authorizations",
discovery: "custom_discovery"

as authorizations: "custom_auth",
tokens: "custom_token",
Expand Down Expand Up @@ -83,6 +86,11 @@
expect(get("/inner_space/scope/token/info")).to route_to("custom_authorizations#show")
end

it "GET /inner_space/.well-known/oauth-authorization-server route to show Discovery controller" do
expect(get("/space/.well-known/oauth-authorization-server")).to route_to("custom_discovery#show")
end


it "GET /space/oauth/authorize routes to custom authorizations controller" do
expect(get("/space/oauth/authorize")).to route_to("custom_authorizations#new")
end
Expand Down Expand Up @@ -115,6 +123,10 @@
expect(get("/space/oauth/token/info")).to route_to("custom_authorizations#show")
end

it "GET /space/.well-known/oauth-authorization-server route to show Discovery controller" do
expect(get("/space/.well-known/oauth-authorization-server")).to route_to("custom_discovery#show")
end

it "POST /outer_space/oauth/token is not be routable" do
expect(post("/outer_space/oauth/token")).not_to be_routable
end
Expand All @@ -130,4 +142,8 @@
it "GET /outer_space/oauth/token_info is not routable" do
expect(get("/outer_space/oauth/token/info")).not_to be_routable
end

it "GET /outer_space/.well-known/oauth-authorization-server route to show Discovery controller" do
expect(get("/outer_space/.well-known/oauth-authorization-server")).to route_to("custom_discovery#show")
end
end
4 changes: 4 additions & 0 deletions spec/routing/default_routes_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,8 @@
it "GET /oauth/token/info route to authorized TokenInfo controller" do
expect(get("/oauth/token/info")).to route_to("doorkeeper/token_info#show")
end

it "GET /.well-known/oauth-authorization-server route to show Discovery controller" do
expect(get("/.well-known/oauth-authorization-server")).to route_to("doorkeeper/discovery#show")
end
end
4 changes: 4 additions & 0 deletions spec/routing/scoped_routes_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,8 @@
it "POST /scope/introspect routes not to exist" do
expect(post("/scope/introspect")).not_to be_routable
end

it "GET /.well-known/oauth-authorization-server route to show Discovery controller" do
expect(get("/.well-known/oauth-authorization-server")).to route_to("doorkeeper/discovery#show")
end
end