Enable OAuth PKCE Extension (#31129)

This commit is contained in:
Emelia Smith 2024-07-26 10:53:10 +02:00 committed by GitHub
parent 3793c845c9
commit 693d9b03ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 164 additions and 6 deletions

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module OauthPreAuthorizationExtension
extend ActiveSupport::Concern
included do
validate :code_challenge_method_s256, error: Doorkeeper::Errors::InvalidCodeChallengeMethod
end
def validate_code_challenge_method_s256
code_challenge.blank? || code_challenge_method == 'S256'
end
end

View File

@ -7,6 +7,7 @@ class OauthMetadataPresenter < ActiveModelSerializers::Model
:revocation_endpoint, :scopes_supported, :revocation_endpoint, :scopes_supported,
:response_types_supported, :response_modes_supported, :response_types_supported, :response_modes_supported,
:grant_types_supported, :token_endpoint_auth_methods_supported, :grant_types_supported, :token_endpoint_auth_methods_supported,
:code_challenge_methods_supported,
:service_documentation, :app_registration_endpoint :service_documentation, :app_registration_endpoint
def issuer def issuer
@ -59,6 +60,10 @@ class OauthMetadataPresenter < ActiveModelSerializers::Model
%w(client_secret_basic client_secret_post) %w(client_secret_basic client_secret_post)
end end
def code_challenge_methods_supported
%w(S256)
end
private private
def doorkeeper def doorkeeper

View File

@ -5,5 +5,6 @@ class OauthMetadataSerializer < ActiveModel::Serializer
:revocation_endpoint, :scopes_supported, :revocation_endpoint, :scopes_supported,
:response_types_supported, :response_modes_supported, :response_types_supported, :response_modes_supported,
:grant_types_supported, :token_endpoint_auth_methods_supported, :grant_types_supported, :token_endpoint_auth_methods_supported,
:code_challenge_methods_supported,
:service_documentation, :app_registration_endpoint :service_documentation, :app_registration_endpoint
end end

View File

@ -26,6 +26,8 @@
value: @pre_auth.client.uid value: @pre_auth.client.uid
= form.hidden_field :redirect_uri, = form.hidden_field :redirect_uri,
value: @pre_auth.redirect_uri value: @pre_auth.redirect_uri
= form.hidden_field :code_challenge, value: @pre_auth.code_challenge
= form.hidden_field :code_challenge_method, value: @pre_auth.code_challenge_method
= form.hidden_field :state, = form.hidden_field :state,
value: @pre_auth.state value: @pre_auth.state
= form.hidden_field :response_type, = form.hidden_field :response_type,
@ -40,6 +42,8 @@
value: @pre_auth.client.uid value: @pre_auth.client.uid
= form.hidden_field :redirect_uri, = form.hidden_field :redirect_uri,
value: @pre_auth.redirect_uri value: @pre_auth.redirect_uri
= form.hidden_field :code_challenge, value: @pre_auth.code_challenge
= form.hidden_field :code_challenge_method, value: @pre_auth.code_challenge_method
= form.hidden_field :state, = form.hidden_field :state,
value: @pre_auth.state value: @pre_auth.state
= form.hidden_field :response_type, = form.hidden_field :response_type,

View File

@ -118,6 +118,7 @@ module Mastodon
Doorkeeper::Application.include ApplicationExtension Doorkeeper::Application.include ApplicationExtension
Doorkeeper::AccessGrant.include AccessGrantExtension Doorkeeper::AccessGrant.include AccessGrantExtension
Doorkeeper::AccessToken.include AccessTokenExtension Doorkeeper::AccessToken.include AccessTokenExtension
Doorkeeper::OAuth::PreAuthorization.include OauthPreAuthorizationExtension
Devise::FailureApp.include AbstractController::Callbacks Devise::FailureApp.include AbstractController::Callbacks
Devise::FailureApp.include Localized Devise::FailureApp.include Localized
end end

View File

@ -83,6 +83,7 @@ en:
access_denied: The resource owner or authorization server denied the request. access_denied: The resource owner or authorization server denied the request.
credential_flow_not_configured: Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured. credential_flow_not_configured: Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.
invalid_client: Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method. invalid_client: Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.
invalid_code_challenge_method: The code challenge method must be S256, plain is unsupported.
invalid_grant: The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client. invalid_grant: The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.
invalid_redirect_uri: The redirect uri included is not valid. invalid_redirect_uri: The redirect uri included is not valid.
invalid_request: invalid_request:

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class EnablePkce < ActiveRecord::Migration[7.1]
def change
add_column :oauth_access_grants, :code_challenge, :string, null: true
add_column :oauth_access_grants, :code_challenge_method, :string, null: true
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_07_13_171909) do ActiveRecord::Schema[7.1].define(version: 2024_07_24_181224) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -739,6 +739,8 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_13_171909) do
t.string "scopes" t.string "scopes"
t.bigint "application_id", null: false t.bigint "application_id", null: false
t.bigint "resource_owner_id", null: false t.bigint "resource_owner_id", null: false
t.string "code_challenge"
t.string "code_challenge_method"
t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id"
t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true
end end

View File

@ -12,6 +12,26 @@ RSpec.describe Oauth::AuthorizationsController do
get :new, params: { client_id: app.uid, response_type: 'code', redirect_uri: 'http://localhost/', scope: 'read' } get :new, params: { client_id: app.uid, response_type: 'code', redirect_uri: 'http://localhost/', scope: 'read' }
end end
def body
Nokogiri::Slop(response.body)
end
def error_message
body.at_css('.form-container .flash-message').text
end
shared_examples 'shows authorize and deny buttons' do
it 'gives options to authorize and deny' do
subject
authorize_button = body.at_css('.oauth-prompt button[type="submit"]:not(.negative)')
deny_button = body.at_css('.oauth-prompt button[type="submit"].negative')
expect(authorize_button).to be_present
expect(deny_button).to be_present
end
end
shared_examples 'stores location for user' do shared_examples 'stores location for user' do
it 'stores location for user' do it 'stores location for user' do
subject subject
@ -36,10 +56,7 @@ RSpec.describe Oauth::AuthorizationsController do
expect(response.headers['Cache-Control']).to include('private, no-store') expect(response.headers['Cache-Control']).to include('private, no-store')
end end
it 'gives options to authorize and deny' do include_examples 'shows authorize and deny buttons'
subject
expect(response.body).to match(/Authorize/)
end
include_examples 'stores location for user' include_examples 'stores location for user'
@ -61,7 +78,110 @@ RSpec.describe Oauth::AuthorizationsController do
it 'does not redirect to callback with force_login=true' do it 'does not redirect to callback with force_login=true' do
get :new, params: { client_id: app.uid, response_type: 'code', redirect_uri: 'http://localhost/', scope: 'read', force_login: 'true' } get :new, params: { client_id: app.uid, response_type: 'code', redirect_uri: 'http://localhost/', scope: 'read', force_login: 'true' }
expect(response.body).to match(/Authorize/)
authorize_button = body.at_css('.oauth-prompt button[type="submit"]:not(.negative)')
deny_button = body.at_css('.oauth-prompt button[type="submit"].negative')
expect(authorize_button).to be_present
expect(deny_button).to be_present
end
end
# The tests in this context ensures that requests without PKCE parameters
# still work; In the future we likely want to force usage of PKCE for
# security reasons, as per:
#
# https://www.ietf.org/archive/id/draft-ietf-oauth-security-topics-27.html#section-2.1.1-9
context 'when not using PKCE' do
subject do
get :new, params: { client_id: app.uid, response_type: 'code', redirect_uri: 'http://localhost/', scope: 'read' }
end
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'does not include the PKCE values in the response' do
subject
code_challenge_input = body.at_css('.oauth-prompt input[name=code_challenge]')
code_challenge_method_input = body.at_css('.oauth-prompt input[name=code_challenge_method]')
expect(code_challenge_input).to be_present
expect(code_challenge_method_input).to be_present
expect(code_challenge_input.attr('value')).to be_nil
expect(code_challenge_method_input.attr('value')).to be_nil
end
include_examples 'shows authorize and deny buttons'
end
context 'when using PKCE' do
subject do
get :new, params: { client_id: app.uid, response_type: 'code', redirect_uri: 'http://localhost/', scope: 'read', code_challenge_method: pkce_code_challenge_method, code_challenge: pkce_code_challenge }
end
let(:pkce_code_challenge) { SecureRandom.hex(32) }
let(:pkce_code_challenge_method) { 'S256' }
context 'when using S256 code challenge method' do
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'includes the PKCE values in the response' do
subject
code_challenge_input = body.at_css('.oauth-prompt input[name=code_challenge]')
code_challenge_method_input = body.at_css('.oauth-prompt input[name=code_challenge_method]')
expect(code_challenge_input).to be_present
expect(code_challenge_method_input).to be_present
expect(code_challenge_input.attr('value')).to eq pkce_code_challenge
expect(code_challenge_method_input.attr('value')).to eq pkce_code_challenge_method
end
include_examples 'shows authorize and deny buttons'
end
context 'when using plain code challenge method' do
subject do
get :new, params: { client_id: app.uid, response_type: 'code', redirect_uri: 'http://localhost/', scope: 'read', code_challenge_method: pkce_code_challenge_method, code_challenge: pkce_code_challenge }
end
let(:pkce_code_challenge_method) { 'plain' }
it 'returns http success' do
subject
expect(response).to have_http_status(400)
end
it 'does not include the PKCE values in the response' do
subject
code_challenge_input = body.at_css('.oauth-prompt input[name=code_challenge]')
code_challenge_method_input = body.at_css('.oauth-prompt input[name=code_challenge_method]')
expect(code_challenge_input).to_not be_present
expect(code_challenge_method_input).to_not be_present
end
it 'does not include the authorize button' do
subject
authorize_button = body.at_css('.oauth-prompt button[type="submit"]')
expect(authorize_button).to_not be_present
end
it 'includes an error message' do
subject
expect(error_message).to match I18n.t('doorkeeper.errors.messages.invalid_code_challenge_method')
end
end end
end end
end end

View File

@ -29,7 +29,10 @@ describe 'The /.well-known/oauth-authorization-server request' do
revocation_endpoint: oauth_revoke_url(protocol: protocol), revocation_endpoint: oauth_revoke_url(protocol: protocol),
scopes_supported: Doorkeeper.configuration.scopes.map(&:to_s), scopes_supported: Doorkeeper.configuration.scopes.map(&:to_s),
response_types_supported: Doorkeeper.configuration.authorization_response_types, response_types_supported: Doorkeeper.configuration.authorization_response_types,
response_modes_supported: Doorkeeper.configuration.authorization_response_flows.flat_map(&:response_mode_matches).uniq,
token_endpoint_auth_methods_supported: %w(client_secret_basic client_secret_post),
grant_types_supported: grant_types_supported, grant_types_supported: grant_types_supported,
code_challenge_methods_supported: ['S256'],
# non-standard extension: # non-standard extension:
app_registration_endpoint: api_v1_apps_url(protocol: protocol) app_registration_endpoint: api_v1_apps_url(protocol: protocol)
) )