From 2075bb789308019511088524e69d25d0bf5ac1ce Mon Sep 17 00:00:00 2001 From: Steve Hobbs Date: Mon, 24 Apr 2023 14:43:31 +0100 Subject: [PATCH] [SDK-4142] Add support for /oauth/par (#470) --- lib/auth0/api/authentication_endpoints.rb | 37 ++++++ lib/auth0/mixins/httpproxy.rb | 7 +- .../api/authentication_endpoints_spec.rb | 90 ++++++++++++++ spec/lib/auth0/mixins/httpproxy_spec.rb | 115 ++++++------------ spec/support/dummy_class_for_tokens.rb | 1 + 5 files changed, 171 insertions(+), 79 deletions(-) diff --git a/lib/auth0/api/authentication_endpoints.rb b/lib/auth0/api/authentication_endpoints.rb index 555ad68e..92b1fd0f 100644 --- a/lib/auth0/api/authentication_endpoints.rb +++ b/lib/auth0/api/authentication_endpoints.rb @@ -323,6 +323,21 @@ def authorization_url(redirect_uri, options = {}) URI::HTTPS.build(host: @domain, path: '/authorize', query: to_query(request_params)) end + # Return an authorization URL for PAR requests + # @see https://www.rfc-editor.org/rfc/rfc9126.html + # @param request_uri [string] The request_uri as obtained by calling `pushed_authorization_request` + # @param additional_parameters Any additional parameters to send + def par_authorization_url(request_uri) + raise Auth0::InvalidParameter, 'Must supply a valid request_uri' if request_uri.to_s.empty? + + request_params = { + client_id: @client_id, + request_uri: request_uri, + } + + URI::HTTPS.build(host: @domain, path: '/authorize', query: to_query(request_params)) + end + # Returns an Auth0 logout URL with a return URL. # @see https://auth0.com/docs/api/authentication#logout # @see https://auth0.com/docs/logout @@ -344,6 +359,28 @@ def logout_url(return_to, include_client: false, federated: false) ) end + # Make a request to the PAR endpoint and receive a `request_uri` to send to the '/authorize' endpoint. + # @see https://auth0.com/docs/api/authentication#authorization-code-grant + # @param redirect_uri [string] URL to redirect after authorization + # @param options [hash] Can contain response_type, connection, state, organization, invitation, and additional_parameters. + # @return [url] Authorization URL. + def pushed_authorization_request(parameters = {}) + request_params = { + client_id: @client_id, + response_type: parameters.fetch(:response_type, 'code'), + connection: parameters.fetch(:connection, nil), + redirect_uri: parameters.fetch(:redirect_uri, nil), + state: parameters.fetch(:state, nil), + scope: parameters.fetch(:scope, nil), + organization: parameters.fetch(:organization, nil), + invitation: parameters.fetch(:invitation, nil) + }.merge(parameters.fetch(:additional_parameters, {})) + + populate_client_assertion_or_secret(request_params) + + request_with_retry(:post_form, '/oauth/par', request_params, {}) + end + # Return a SAMLP URL. # The SAML Request AssertionConsumerServiceURL will be used to POST back # the assertion and it must match with the application callback URL. diff --git a/lib/auth0/mixins/httpproxy.rb b/lib/auth0/mixins/httpproxy.rb index 744277c8..f4446c47 100644 --- a/lib/auth0/mixins/httpproxy.rb +++ b/lib/auth0/mixins/httpproxy.rb @@ -16,7 +16,7 @@ module HTTPProxy BASE_DELAY = 100 # proxying requests from instance methods to HTTP class methods - %i(get post post_file put patch delete delete_with_body).each do |method| + %i(get post post_file post_form put patch delete delete_with_body).each do |method| define_method(method) do |uri, body = {}, extra_headers = {}| body = body.delete_if { |_, v| v.nil? } token = get_token() @@ -85,9 +85,12 @@ def request(method, uri, body = {}, extra_headers = {}) elsif method == :post_file body.merge!(multipart: true) # Ignore the default Content-Type headers and let the HTTP client define them - post_file_headers = headers.slice(*headers.keys - ['Content-Type']) + post_file_headers = headers.except('Content-Type') if headers != nil # Actual call with the altered headers call(:post, encode_uri(uri), timeout, post_file_headers, body) + elsif method == :post_form + form_post_headers = headers.except('Content-Type') if headers != nil + call(:post, encode_uri(uri), timeout, form_post_headers, body.compact) else call(method, encode_uri(uri), timeout, headers, body.to_json) end diff --git a/spec/lib/auth0/api/authentication_endpoints_spec.rb b/spec/lib/auth0/api/authentication_endpoints_spec.rb index c5d467fd..09789955 100644 --- a/spec/lib/auth0/api/authentication_endpoints_spec.rb +++ b/spec/lib/auth0/api/authentication_endpoints_spec.rb @@ -6,6 +6,7 @@ let(:client_secret) { 'test-client-secret' } let(:api_identifier) { 'test-audience' } let(:domain) { 'samples.auth0.com' } + let(:request_uri) { 'urn:ietf:params:oauth:request_uri:the.request.uri' } let(:client_secret_config) { { domain: domain, @@ -628,5 +629,94 @@ client_assertion_instance.send :start_passwordless_sms_flow, '123456789' end end + + context 'par_authorization_url' do + it 'throws an exception if request_uri is nil' do + expect { client_secret_instance.send :par_authorization_url, nil}.to raise_error Auth0::InvalidParameter + end + + it 'throws an exception if request_uri is empty' do + expect { client_secret_instance.send :par_authorization_url, ''}.to raise_error Auth0::InvalidParameter + end + + it 'builds a URL containing the request_uri' do + url = client_secret_instance.send :par_authorization_url, request_uri + expect(CGI.unescape(url.to_s)).to eq("https://samples.auth0.com/authorize?client_id=#{client_id}&request_uri=#{request_uri}") + end + end + + context 'pushed_authorization_request' do + it 'sends the request as a form post' do + expect(RestClient::Request).to receive(:execute) do |arg| + expect(arg[:url]).to eq('https://samples.auth0.com/oauth/par') + expect(arg[:method]).to eq(:post) + + expect(arg[:payload]).to eq({ + client_id: client_id, + client_secret: client_secret, + response_type: 'code', + }) + + StubResponse.new({}, true, 200) + end + + client_secret_instance.send :pushed_authorization_request + end + + it 'allows the RestClient to handle the correct header defaults' do + expect(RestClient::Request).to receive(:execute) do |arg| + expect(arg[:headers]).not_to have_key('Content-Type') + + StubResponse.new({}, true, 200) + end + + client_secret_instance.headers['Content-Type'] = 'application/x-www-form-urlencoded' + client_secret_instance.send :pushed_authorization_request + end + + it 'sends the request as a form post with all known overrides' do + expect(RestClient::Request).to receive(:execute) do |arg| + expect(arg[:url]).to eq('https://samples.auth0.com/oauth/par') + expect(arg[:method]).to eq(:post) + + expect(arg[:payload]).to eq({ + client_id: client_id, + client_secret: client_secret, + connection: 'google-oauth2', + organization: 'org_id', + invitation: 'http://invite.url', + redirect_uri: 'http://localhost:3000', + response_type: 'id_token', + scope: 'openid', + state: 'random_value' + }) + + StubResponse.new({}, true, 200) + end + + client_secret_instance.send(:pushed_authorization_request, + response_type: 'id_token', + redirect_uri: 'http://localhost:3000', + organization: 'org_id', + invitation: 'http://invite.url', + scope: 'openid', + state: 'random_value', + connection: 'google-oauth2') + end + + it 'sends the request as a form post using client assertion' do + expect(RestClient::Request).to receive(:execute) do |arg| + expect(arg[:url]).to eq('https://samples.auth0.com/oauth/par') + expect(arg[:method]).to eq(:post) + expect(arg[:payload][:client_secret]).to be_nil + expect(arg[:payload][:client_assertion]).not_to be_nil + expect(arg[:payload][:client_assertion_type]).to eq Auth0::ClientAssertion::CLIENT_ASSERTION_TYPE + + StubResponse.new({}, true, 200) + end + + client_assertion_instance.send :pushed_authorization_request + end + end end end \ No newline at end of file diff --git a/spec/lib/auth0/mixins/httpproxy_spec.rb b/spec/lib/auth0/mixins/httpproxy_spec.rb index de427d99..40211896 100644 --- a/spec/lib/auth0/mixins/httpproxy_spec.rb +++ b/spec/lib/auth0/mixins/httpproxy_spec.rb @@ -250,25 +250,37 @@ end end - %i(post put patch).each do |http_method| + def expected_payload(method, overrides = {}) + if method == :post_form + { + method: :post, + url: 'https://auth0.com/test', + timeout: nil, + headers: nil, + payload: {} + }.merge(overrides) + else + { + method: method, + url: 'https://auth0.com/test', + timeout: nil, + headers: nil, + payload: '{}' + }.merge(overrides) + end + end + + %i(post post_form put patch).each do |http_method| context ".#{http_method}" do it { expect(@instance).to respond_to(http_method.to_sym) } - it "should call send http #{http_method} method to path defined through HTTP" do - expect(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + it "should call send http #{http_method} method to path defined through HTTP"do + expect(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_return(StubResponse.new({}, true, 200)) expect { @instance.send(http_method, '/test') }.not_to raise_error end it 'should not raise exception if data returned not in json format (should be fixed in v2)' do - allow(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_return(StubResponse.new('Some random text here', true, 200)) expect { @instance.send(http_method, '/test') }.not_to raise_error expect(@instance.send(http_method, '/test')).to eql('Some random text here') @@ -277,11 +289,7 @@ it "should raise Auth0::Unauthorized on send http #{http_method} method to path defined through HTTP when 401 status received" do @exception.response = StubResponse.new({}, false, 401) - allow(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Auth0::Unauthorized) end @@ -294,11 +302,7 @@ :x_ratelimit_reset => 1560564149 } @exception.response = StubResponse.new({}, false, 429,headers) - allow(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error { |error| expect(error).to be_a(Auth0::RateLimitEncountered) @@ -317,11 +321,7 @@ it "should raise Auth0::NotFound on send http #{http_method} method to path defined through HTTP when 404 status received" do @exception.response = StubResponse.new({}, false, 404) - allow(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Auth0::NotFound) end @@ -329,22 +329,14 @@ it "should raise Auth0::Unsupported on send http #{http_method} method to path defined through HTTP when 418 or other unknown status received" do @exception.response = StubResponse.new({}, false, 418) - allow(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Auth0::Unsupported) end it "should raise Auth0::RequestTimeout on send http #{http_method} method to path defined through HTTP when RestClient::RequestTimeout received" do - allow(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_raise(RestClient::Exceptions::OpenTimeout.new) expect { @instance.send(http_method, '/test') }.to raise_error(Auth0::RequestTimeout) end @@ -352,11 +344,7 @@ it "should raise Auth0::BadRequest on send http #{http_method} method to path defined through HTTP when 400 status received" do @exception.response = StubResponse.new({}, false, 400) - allow(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Auth0::BadRequest) end @@ -364,20 +352,13 @@ it "should raise Auth0::ServerError on send http #{http_method} method to path defined through HTTP when 500 received" do @exception.response = StubResponse.new({}, false, 500) - allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Auth0::ServerError) end it 'should normalize path with Addressable::URI' do - expect(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/te%20st', - timeout: nil, - headers: nil, - payload: '{}') + expect(RestClient::Request).to receive(:execute).with(expected_payload(http_method, url: 'https://auth0.com/te%20st')) .and_return(StubResponse.new({}, true, 200)) expect { @instance.send(http_method, '/te st') }.not_to raise_error end @@ -388,11 +369,7 @@ 'message' => "Path validation error: 'String does not match pattern ^.+\\|.+$: 3241312' on property id (The user_id of the user to retrieve).", 'errorCode' => 'invalid_uri') - expect(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + expect(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_return(StubResponse.new(res, true, 404)) expect { @instance.send(http_method, '/test') }.to raise_error(Auth0::NotFound, res) end @@ -404,11 +381,7 @@ retry_instance.base_uri = "https://auth0.com" @exception.response = StubResponse.new({}, false, 429) - allow(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_raise(@exception) expect(RestClient::Request).to receive(:execute).exactly(4).times @@ -424,11 +397,7 @@ retry_instance.retry_count = 2 @exception.response = StubResponse.new({}, false, 429) - allow(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_raise(@exception) expect(RestClient::Request).to receive(:execute).exactly(3).times @@ -445,11 +414,7 @@ @exception.response = StubResponse.new({}, false, 429) - allow(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) .and_raise(@exception) expect(RestClient::Request).to receive(:execute).exactly(1).times @@ -467,11 +432,7 @@ @time_start @exception.response = StubResponse.new({}, false, 429) - allow(RestClient::Request).to receive(:execute).with(method: http_method, - url: 'https://auth0.com/test', - timeout: nil, - headers: nil, - payload: '{}') do + allow(RestClient::Request).to receive(:execute).with(expected_payload(http_method)) do time_entries.push(Time.now.to_f - @time_start.to_f) @time_start = Time.now.to_f # restart the clock @@ -492,6 +453,7 @@ end end end +end context "Renewing tokens" do let(:httpproxy_instance) { @@ -546,7 +508,6 @@ end end end - end context "Using cached tokens" do let(:httpproxy_instance) { diff --git a/spec/support/dummy_class_for_tokens.rb b/spec/support/dummy_class_for_tokens.rb index 38c5a0ce..2b4c8461 100644 --- a/spec/support/dummy_class_for_tokens.rb +++ b/spec/support/dummy_class_for_tokens.rb @@ -15,5 +15,6 @@ def initialize(config) @token_expires_at = config[:token_expires_at] @client_assertion_signing_key = config[:client_assertion_signing_key] @client_assertion_signing_alg = config[:client_assertion_signing_alg] || 'RS256' + @headers ||= {} end end \ No newline at end of file