From 116f01ec7d1e0793fc6c1749867d660d7c19a5b7 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Mon, 6 May 2024 15:17:56 +0200 Subject: [PATCH] Implement RFC 8414 for OAuth 2.0 server metadata (#29191) --- .../well_known/oauth_metadata_controller.rb | 23 +++++++ app/presenters/oauth_metadata_presenter.rb | 67 +++++++++++++++++++ app/serializers/oauth_metadata_serializer.rb | 9 +++ config/routes.rb | 1 + .../well_known/oauth_metadata_spec.rb | 37 ++++++++++ 5 files changed, 137 insertions(+) create mode 100644 app/controllers/well_known/oauth_metadata_controller.rb create mode 100644 app/presenters/oauth_metadata_presenter.rb create mode 100644 app/serializers/oauth_metadata_serializer.rb create mode 100644 spec/requests/well_known/oauth_metadata_spec.rb diff --git a/app/controllers/well_known/oauth_metadata_controller.rb b/app/controllers/well_known/oauth_metadata_controller.rb new file mode 100644 index 0000000000..c80be2d652 --- /dev/null +++ b/app/controllers/well_known/oauth_metadata_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module WellKnown + class OauthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController + include CacheConcern + + # Prevent `active_model_serializer`'s `ActionController::Serialization` from calling `current_user` + # and thus re-issuing session cookies + serialization_scope nil + + def show + # Due to this document potentially changing between Mastodon versions (as + # new OAuth scopes are added), we don't use expires_in to cache upstream, + # instead just caching in the rails cache: + render_with_cache( + json: ::OauthMetadataPresenter.new, + serializer: ::OauthMetadataSerializer, + content_type: 'application/json', + expires_in: 15.minutes + ) + end + end +end diff --git a/app/presenters/oauth_metadata_presenter.rb b/app/presenters/oauth_metadata_presenter.rb new file mode 100644 index 0000000000..546503bfcc --- /dev/null +++ b/app/presenters/oauth_metadata_presenter.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class OauthMetadataPresenter < ActiveModelSerializers::Model + include RoutingHelper + + attributes :issuer, :authorization_endpoint, :token_endpoint, + :revocation_endpoint, :scopes_supported, + :response_types_supported, :response_modes_supported, + :grant_types_supported, :token_endpoint_auth_methods_supported, + :service_documentation, :app_registration_endpoint + + def issuer + root_url + end + + def service_documentation + 'https://docs.joinmastodon.org/' + end + + def authorization_endpoint + oauth_authorization_url + end + + def token_endpoint + oauth_token_url + end + + # As the api_v1_apps route doesn't technically conform to the specification + # for OAuth 2.0 Dynamic Client Registration defined in RFC 7591 we use a + # non-standard property for now to indicate the mastodon specific registration + # endpoint. See: https://datatracker.ietf.org/doc/html/rfc7591 + def app_registration_endpoint + api_v1_apps_url + end + + def revocation_endpoint + oauth_revoke_url + end + + def scopes_supported + doorkeeper.scopes + end + + def response_types_supported + doorkeeper.authorization_response_types + end + + def response_modes_supported + doorkeeper.authorization_response_flows.flat_map(&:response_mode_matches).uniq + end + + def grant_types_supported + grant_types_supported = doorkeeper.grant_flows.dup + grant_types_supported << 'refresh_token' if doorkeeper.refresh_token_enabled? + grant_types_supported + end + + def token_endpoint_auth_methods_supported + %w(client_secret_basic client_secret_post) + end + + private + + def doorkeeper + @doorkeeper ||= Doorkeeper.configuration + end +end diff --git a/app/serializers/oauth_metadata_serializer.rb b/app/serializers/oauth_metadata_serializer.rb new file mode 100644 index 0000000000..5f3dc7b87e --- /dev/null +++ b/app/serializers/oauth_metadata_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class OauthMetadataSerializer < ActiveModel::Serializer + attributes :issuer, :authorization_endpoint, :token_endpoint, + :revocation_endpoint, :scopes_supported, + :response_types_supported, :response_modes_supported, + :grant_types_supported, :token_endpoint_auth_methods_supported, + :service_documentation, :app_registration_endpoint +end diff --git a/config/routes.rb b/config/routes.rb index 3d3c94096c..f4662dd5da 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -62,6 +62,7 @@ Rails.application.routes.draw do tokens: 'oauth/tokens' end + get '.well-known/oauth-authorization-server', to: 'well_known/oauth_metadata#show', as: :oauth_metadata, defaults: { format: 'json' } get '.well-known/host-meta', to: 'well_known/host_meta#show', as: :host_meta, defaults: { format: 'xml' } get '.well-known/nodeinfo', to: 'well_known/node_info#index', as: :nodeinfo, defaults: { format: 'json' } get '.well-known/webfinger', to: 'well_known/webfinger#show', as: :webfinger diff --git a/spec/requests/well_known/oauth_metadata_spec.rb b/spec/requests/well_known/oauth_metadata_spec.rb new file mode 100644 index 0000000000..deef189ac9 --- /dev/null +++ b/spec/requests/well_known/oauth_metadata_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'The /.well-known/oauth-authorization-server request' do + let(:protocol) { ENV.fetch('LOCAL_HTTPS', true) ? :https : :http } + + before do + host! ENV.fetch('LOCAL_DOMAIN') + end + + it 'returns http success with valid JSON response' do + get '/.well-known/oauth-authorization-server' + + expect(response) + .to have_http_status(200) + .and have_attributes( + media_type: 'application/json' + ) + + grant_types_supported = Doorkeeper.configuration.grant_flows.dup + grant_types_supported << 'refresh_token' if Doorkeeper.configuration.refresh_token_enabled? + + expect(body_as_json).to include( + issuer: root_url(protocol: protocol), + service_documentation: 'https://docs.joinmastodon.org/', + authorization_endpoint: oauth_authorization_url(protocol: protocol), + token_endpoint: oauth_token_url(protocol: protocol), + revocation_endpoint: oauth_revoke_url(protocol: protocol), + scopes_supported: Doorkeeper.configuration.scopes.map(&:to_s), + response_types_supported: Doorkeeper.configuration.authorization_response_types, + grant_types_supported: grant_types_supported, + # non-standard extension: + app_registration_endpoint: api_v1_apps_url(protocol: protocol) + ) + end +end