diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aea8fb541..c51baf5b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [1.18.4] - 2022-09-11 + +### Added +- Adds support for authorization token in header in OIDC authenticator. + [cyberark/conjur#2637](https://github.com/cyberark/conjur/pull/2637) + ## [1.18.3] - 2022-09-07 ### Security diff --git a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb index aeffc47680..c5cc648845 100644 --- a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb +++ b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb @@ -11,14 +11,14 @@ module AuthnOidc inputs: %i[authenticator_input] ) do extend(Forwardable) + # The 'request' field in the 'authenticator_input' object will be used to extract the id token from the request header def_delegators(:@authenticator_input, :service_id, :authenticator_name, - :account, :username, :webservice, :credentials, :client_ip, + :account, :username, :webservice, :credentials, :client_ip, :request, :role) def call validate_account_exists validate_service_id_exists - validate_credentials_include_id_token verify_and_decode_token validate_conjur_username input_with_username @@ -36,15 +36,6 @@ def validate_service_id_exists raise Errors::Authentication::AuthnOidc::ServiceIdMissing unless service_id end - def validate_credentials_include_id_token - id_token_field_name = "id_token" - - # check that id token field exists and has some value - if decoded_credentials.fetch(id_token_field_name, "") == "" - raise Errors::Authentication::RequestBody::MissingRequestParam, id_token_field_name - end - end - def verify_and_decode_token @decoded_token = @verify_and_decode_token.( provider_uri: oidc_authenticator_secrets["provider-uri"], @@ -53,9 +44,45 @@ def verify_and_decode_token ) end - # The credentials are in a URL encoded form data in the request body + # The credentials are in a URL encoded form data in the request body or in the request header def decoded_credentials - @decoded_credentials ||= Hash[URI.decode_www_form(credentials)] + @decoded_credentials ||= begin + return token_from_body if token_from_body + + return token_from_header if token_from_header + + # If the token is not in the header or body, raise a missing param exception + raise Errors::Authentication::RequestBody::MissingRequestParam, 'id_token' + end + end + + def token_from_header + @token_from_header ||= begin + return nil unless request&.headers&.key?("HTTP_AUTHORIZATION") + + # Extract the token from the authorization header + id_token = request.headers['HTTP_AUTHORIZATION'].split(' ', -1) + + # Verify the token is given as a bearer token + return nil unless id_token[0] == "Bearer" && id_token.length == 2 + + # Return the token formatted as it would be if extracted from the body + # as `application/x-www-form-urlencoded` data. + { + # URL decode the ID token from the header + "id_token" => URI.decode_www_form_component(id_token[1]) + } + end + end + + def token_from_body + @token_from_body ||= begin + # Parse the request body + credential_token = Hash[URI.decode_www_form(credentials)] + + # Return the parsed body if it include the token + credential_token unless credential_token.fetch('id_token', "").empty? + end end def oidc_authenticator_secrets diff --git a/cucumber/authenticators_oidc/features/authn_oidc.feature b/cucumber/authenticators_oidc/features/authn_oidc.feature index a270abf14b..d603ebc7fc 100644 --- a/cucumber/authenticators_oidc/features/authn_oidc.feature +++ b/cucumber/authenticators_oidc/features/authn_oidc.feature @@ -39,7 +39,23 @@ Feature: OIDC Authenticator - Hosts can authenticate with OIDC authenticator And I successfully set OIDC variables @smoke - Scenario: A valid id token to get Conjur access token + Scenario: A valid id token in header to get Conjur access token + # We want to verify the returned access token is valid for retrieving a secret + Given I have a "variable" resource called "test-variable" + And I permit user "alice" to "execute" it + And I add the secret value "test-secret" to the resource "cucumber:variable:test-variable" + And I fetch an ID Token for username "alice" and password "alice" + And I save my place in the audit log file + When I authenticate via OIDC with id token in header + Then user "alice" has been authorized by Conjur + And I successfully GET "/secrets/cucumber/variable/test-variable" with authorized user + And The following appears in the audit log after my savepoint: + """ + cucumber:user:alice successfully authenticated with authenticator authn-oidc service cucumber:webservice:conjur/authn-oidc/keycloak + """ + + @smoke + Scenario: A valid id token in body to get Conjur access token # We want to verify the returned access token is valid for retrieving a secret Given I have a "variable" resource called "test-variable" And I permit user "alice" to "execute" it diff --git a/cucumber/authenticators_oidc/features/step_definitions/authn_oidc_steps.rb b/cucumber/authenticators_oidc/features/step_definitions/authn_oidc_steps.rb index 69e35b0eb6..00b54b3697 100644 --- a/cucumber/authenticators_oidc/features/step_definitions/authn_oidc_steps.rb +++ b/cucumber/authenticators_oidc/features/step_definitions/authn_oidc_steps.rb @@ -161,6 +161,13 @@ ) end +When(/^I authenticate via OIDC with id token in header$/) do + authenticate_id_token_with_oidc_in_header( + service_id: AuthnOidcHelper::SERVICE_ID, + account: AuthnOidcHelper::ACCOUNT + ) +end + Given(/^I successfully set OIDC V2 variables for "([^"]*)"$/) do |service_id| create_oidc_secret("provider-uri", oidc_provider_uri, service_id) create_oidc_secret("response-type", oidc_response_type, service_id) diff --git a/cucumber/authenticators_oidc/features/support/authn_oidc_helper.rb b/cucumber/authenticators_oidc/features/support/authn_oidc_helper.rb index 42c1e744d3..fab2bb92d4 100644 --- a/cucumber/authenticators_oidc/features/support/authn_oidc_helper.rb +++ b/cucumber/authenticators_oidc/features/support/authn_oidc_helper.rb @@ -24,6 +24,14 @@ def authenticate_id_token_with_oidc(service_id:, account:, id_token: parsed_id_t post(path, payload) end + def authenticate_id_token_with_oidc_in_header(service_id:, account:, id_token: parsed_id_token) + service_id_part = service_id ? "/#{service_id}" : "" + path = "#{conjur_hostname}/authn-oidc#{service_id_part}/#{account}/authenticate" + headers = {} + headers["Authorization"] = "Bearer #{id_token}" + post(path, {}, headers) + end + def authenticate_code_with_oidc(service_id:, account:, code: url_oidc_code, state: url_oidc_state) path = "#{create_auth_url(service_id: service_id, account: account, user_id: nil)}" get(url_with_params(path: path,code: code, state: state )) diff --git a/spec/app/domain/authentication/authn-oidc/update_input_with_username_from_id_token_spec.rb b/spec/app/domain/authentication/authn-oidc/update_input_with_username_from_id_token_spec.rb index 63271e6963..5771599528 100644 --- a/spec/app/domain/authentication/authn-oidc/update_input_with_username_from_id_token_spec.rb +++ b/spec/app/domain/authentication/authn-oidc/update_input_with_username_from_id_token_spec.rb @@ -27,13 +27,14 @@ # request mock #################################### - def mock_authenticate_oidc_request(request_body_data:) + def mock_authenticate_oidc_request(request_body_data: , request_headers:) double('AuthnOidcRequest').tap do |request| request_body = StringIO.new request_body.puts(request_body_data) request_body.rewind allow(request).to receive(:body).and_return(request_body) + allow(request).to receive(:headers).and_return(request_headers) end end @@ -42,23 +43,43 @@ def request_body(request) end let(:authenticate_id_token_request) do - mock_authenticate_oidc_request(request_body_data: "id_token={\"id_token_username_field\": \"alice\"}") + mock_authenticate_oidc_request(request_body_data: "id_token={\"id_token_username_field\": \"alice\"}", request_headers: nil) end let(:authenticate_id_token_request_missing_id_token_username_field) do - mock_authenticate_oidc_request(request_body_data: "id_token={}") + mock_authenticate_oidc_request(request_body_data: "id_token={}", request_headers: nil) end let(:authenticate_id_token_request_empty_id_token_username_field) do - mock_authenticate_oidc_request(request_body_data: "id_token={\"id_token_username_field\": \"\"}") + mock_authenticate_oidc_request(request_body_data: "id_token={\"id_token_username_field\": \"\"}", request_headers: nil) end let(:authenticate_id_token_request_missing_id_token_field) do - mock_authenticate_oidc_request(request_body_data: "some_key=some_value") + mock_authenticate_oidc_request(request_body_data: "some_key=some_value", request_headers: nil) end let(:authenticate_id_token_request_empty_id_token_field) do - mock_authenticate_oidc_request(request_body_data: "id_token=") + mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: nil) + end + + let(:authenticate_id_token_request_id_token_in_header_field) do + mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => "Bearer {\"id_token_username_field\":\"alice\"}"}) + end + + let(:authenticate_id_token_request_invalid_id_token_in_header_field) do + mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => "{\"id_token_username_field\":\"alice\"}"}) + end + + let(:authenticate_id_token_request_empty_id_token_in_header_field) do + mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => ""}) + end + + let(:authenticate_id_token_request_missing_id_token_in_body_field) do + mock_authenticate_oidc_request(request_body_data: "", request_headers: {"HTTP_AUTHORIZATION" => "Bearer {\"id_token_username_field\":\"alice\"}"}) + end + + let(:authenticate_id_token_request_contain_only_bearer_in_header_field) do + mock_authenticate_oidc_request(request_body_data: "", request_headers: {"HTTP_AUTHORIZATION" => "Bearer"}) end # ____ _ _ ____ ____ ____ ___ ____ ___ @@ -247,6 +268,141 @@ def request_body(request) expect { subject }.to raise_error(::Errors::Authentication::RequestBody::MissingRequestParam) end end + + context "with valid id token in header" do + let(:audit_success) { false } + + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_request_id_token_in_header_field), + client_ip: '127.0.0.1', + request: authenticate_id_token_request_id_token_in_header_field + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "returns the input with the username inside it" do + expect(subject.username).to eql("alice") + end + end + + context "with invalid id token in header and in body" do + let(:audit_success) { false } + + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_request_invalid_id_token_in_header_field), + client_ip: '127.0.0.1', + request: authenticate_id_token_request_invalid_id_token_in_header_field + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "raises a MissingRequestParam error" do + expect { subject }.to raise_error(::Errors::Authentication::RequestBody::MissingRequestParam) + end + end + + context "with empty id token in header and invalid in body" do + let(:audit_success) { false } + + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_request_empty_id_token_in_header_field), + client_ip: '127.0.0.1', + request: authenticate_id_token_request_empty_id_token_in_header_field + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "raises a MissingRequestParam error" do + expect { subject }.to raise_error(::Errors::Authentication::RequestBody::MissingRequestParam) + end + end + + context "with valid id token in header and empty body" do + let(:audit_success) { false } + + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_request_missing_id_token_in_body_field), + client_ip: '127.0.0.1', + request: authenticate_id_token_request_missing_id_token_in_body_field + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "returns the input with the username inside it" do + expect(subject.username).to eql("alice") + end + end + + context "with bearer only in id token in header and empty body" do + let(:audit_success) { false } + + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_request_contain_only_bearer_in_header_field), + client_ip: '127.0.0.1', + request: authenticate_id_token_request_contain_only_bearer_in_header_field + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "raises a MissingRequestParam error" do + expect { subject }.to raise_error(::Errors::Authentication::RequestBody::MissingRequestParam) + end + end end end end