diff --git a/CHANGELOG.md b/CHANGELOG.md index 3385cf7..5dc9c41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### :magic_wand: Added - [Federated Authentication Plugin](https://aws.github.io/aws-advanced-wrapper-docs/ruby/using-plugins/federated-auth), which authenticates with Active Directory Federation Services (AD FS), exchanges the resulting SAML assertion for temporary AWS credentials via STS, and connects to the database with an RDS IAM authentication token. Enabled with the `federated_auth` plugin code ([PR #207](https://github.com/aws/aws-advanced-ruby-driver-wrapper/pull/207)). +- [Okta Authentication Plugin](https://aws.github.io/aws-advanced-wrapper-docs/ruby/using-plugins/okta), which uses the same SAML-to-STS flow as AD FS but against Okta, is also available via the `okta` plugin code ([PR #215](https://github.com/aws/aws-advanced-ruby-driver-wrapper/pull/215)). ### :crab: Changed - Simplified the KMS Encryption Plugin's PostgreSQL trigger SQL to read the column's HMAC key from `key_storage` for every row. Re-run the trigger SQL from the [documentation](https://aws.github.io/aws-advanced-wrapper-docs/ruby/using-plugins/kms-encryption#postgresql-1) to update existing deployments ([PR #216](https://github.com/aws/aws-advanced-ruby-driver-wrapper/pull/216)). diff --git a/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb b/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb index 4c71791..5156deb 100644 --- a/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb +++ b/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb @@ -31,7 +31,6 @@ require_relative 'switchover_state' require_relative 'switchover_timer' require_relative '../../plugins/iam_auth_plugin' -require_relative '../../plugins/federated_auth/federated_auth_plugin' module AwsAdvancedRubyDriverWrapper module Plugins @@ -192,7 +191,7 @@ def reset_context_when_completed @monitors = { Role::SOURCE => nil, Role::TARGET => nil } old_monitors.each_value { |m| m&.stop } - # IamAuthPlugin and FederatedAuthPlugin all cache tokens under the same + # IamAuthPlugin, FederatedAuthPlugin, and OktaAuthPlugin all cache tokens under the same # shared IAM_TOKEN_CACHE_NAME, so clearing through any one of them clears all of them. Plugins::IamAuthPlugin.clear_cache(@storage_service) if iam_token_cache_in_use? @@ -360,7 +359,7 @@ def switchover_finalized? end # True when any auth plugin backed by the shared IAM token cache (IAM or AD FS - # federated auth) is in use, so a switchover knows to purge that cache regardless + # or Okta) is in use, so a switchover knows to purge that cache regardless # of which one is active. def iam_token_cache_in_use? @service_container.plugin_manager&.iam_token_plugin_in_use? || false diff --git a/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/okta_auth_plugin.rb b/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/okta_auth_plugin.rb new file mode 100644 index 0000000..15186ef --- /dev/null +++ b/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/okta_auth_plugin.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'cgi' +require 'json' +require_relative 'base_saml_auth_plugin' + +module AwsAdvancedRubyDriverWrapper + module Plugins + module FederatedAuth + # Federated authentication against Okta. Authenticates to Okta's /authn API + # for a one-time session token, redeems it at the AWS app's SSO SAML endpoint + # for a SAML assertion, and then defers to BaseSamlAuthPlugin for the STS + # exchange, token minting, caching, and retry. Okta differs from AD FS only in + # configuration (it uses app_id, the AWS app configured on Okta) and in the + # shape of the IdP HTTP flow. + class OktaAuthPlugin < AwsAdvancedRubyDriverWrapper::Plugins::FederatedAuth::BaseSamlAuthPlugin + OKTA_AWS_APP_NAME = 'amazon_aws' + # Extracts the value attribute of the hidden SAMLResponse input regardless + # of attribute order. + SAML_INPUT_PATTERN = /]*\bname="SAMLResponse"[^>]*>/i + VALUE_ATTR_PATTERN = /\bvalue="([^"]+)"/i + + # Fetches a SAML assertion from Okta. + # + # @return [String] the base64 SAML assertion + def self.fetch_saml_assertion(idp_endpoint:, app_id:, idp_username:, idp_password:, + ssl_insecure:, socket_timeout_sec:, connect_timeout_sec:, **_rest) + http = lambda do |method, url, body: nil, headers: {}| + Utils::SamlUtils.http_request( + method:, url:, body:, headers:, ssl_insecure:, + open_timeout_sec: connect_timeout_sec, read_timeout_sec: socket_timeout_sec + ) + end + + session_token = session_token(http, idp_endpoint, idp_username, idp_password) + saml_url = "https://#{idp_endpoint}/app/#{OKTA_AWS_APP_NAME}/#{CGI.escape(app_id)}/sso/saml" \ + "?onetimetoken=#{CGI.escape(session_token)}" + response = http.call(:get, saml_url) + unless response.is_a?(Net::HTTPSuccess) + raise Errors::SamlAuthError, + "OktaAuthPlugin: SAML request failed (#{response.code} #{response.message})" + end + + tag = SAML_INPUT_PATTERN.match(response.body) + value = tag && VALUE_ATTR_PATTERN.match(tag[0]) + raise Errors::SamlAuthError, 'OktaAuthPlugin: invalid SAML response from Okta' unless value + + # The value attribute is HTML-entity-encoded (Okta encodes base64's trailing + # "=" padding, and "+" if present, as e.g. = / +); decode before + # handing the assertion to STS, matching FederatedAuthPlugin's AD FS flow. + CGI.unescapeHTML(value[1]) + end + + # Authenticates to Okta's /authn API and returns the one-time session token. + def self.session_token(http, idp_endpoint, idp_username, idp_password) + response = http.call( + :post, "https://#{idp_endpoint}/api/v1/authn", + body: JSON.generate(username: idp_username, password: idp_password), + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + unless response.is_a?(Net::HTTPSuccess) + raise Errors::SamlAuthError, + "OktaAuthPlugin: Okta session token request failed (#{response.code} #{response.message})" + end + + parsed = begin + JSON.parse(response.body) + rescue JSON::ParserError + nil + end + parsed = nil unless parsed.is_a?(Hash) + token = parsed && parsed['sessionToken'] + if token.nil? || token.empty? + # Okta's `status` is a non-sensitive auth-state enum (e.g. MFA_REQUIRED, + # PASSWORD_EXPIRED, LOCKED_OUT); surface it for clarity. It never carries + # credentials or the session token itself. + status = parsed && parsed['status'] + detail = status.is_a?(String) && !status.empty? ? " (status: #{status})" : '' + raise Errors::SamlAuthError, "OktaAuthPlugin: Okta did not return a session token#{detail}" + end + + token + end + + private + + def required_identity(saml_props) + super + [['app_id', PropertyDefinition::APP_ID.get(saml_props)]] + end + + def saml_fetch_options(saml_props, idp_username, idp_password) + super.merge(app_id: PropertyDefinition::APP_ID.get(saml_props)) + end + end + end + end +end diff --git a/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb b/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb index 3b02237..9d952a9 100644 --- a/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb +++ b/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb @@ -174,8 +174,6 @@ module PropertyDefinition # -- Federated / Okta Authentication (SAML) -- IDP_ENDPOINT = WrapperProperty.new(:idp_endpoint, 'The hosting URL of the Identity Provider', default_value: nil, type: String) IDP_PORT = WrapperProperty.new(:idp_port, 'The hosting port of the Identity Provider', default_value: 443, type: Integer) - IDP_NAME = WrapperProperty.new(:idp_name, 'The name of the Identity Provider implementation used', default_value: 'adfs', - type: String) IDP_USERNAME = WrapperProperty.new(:idp_username, 'The federated user name', default_value: nil, type: String) IDP_PASSWORD = WrapperProperty.new(:idp_password, 'The federated user password', default_value: nil, type: String) IAM_ROLE_ARN = WrapperProperty.new(:iam_role_arn, 'The ARN of the IAM Role that is to be assumed', default_value: nil, type: String) @@ -185,6 +183,7 @@ module PropertyDefinition DB_USER = WrapperProperty.new(:db_user, 'The database user used to access the database', default_value: nil, type: String) SSL_INSECURE = WrapperProperty.new(:ssl_insecure, "Whether the IdP server's TLS certificate chain should not be verified", default_value: false, type: :boolean) + APP_ID = WrapperProperty.new(:app_id, 'The ID of the AWS application configured on Okta', default_value: nil, type: String) HTTP_CLIENT_SOCKET_TIMEOUT_SEC = WrapperProperty.new(:http_client_socket_timeout_sec, 'Socket timeout in seconds for IdP HTTP requests', default_value: 60.0, type: Float, validator: POSITIVE_FLOAT) diff --git a/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb b/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb index 15b3b6e..9ca3287 100644 --- a/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb +++ b/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb @@ -24,6 +24,7 @@ require_relative '../plugins/gdb/gdb_failover_plugin' require_relative '../plugins/iam_auth_plugin' require_relative '../plugins/federated_auth/federated_auth_plugin' +require_relative '../plugins/federated_auth/okta_auth_plugin' require_relative '../plugins/initial_connection_strategy_plugin' require_relative '../plugins/kms_encryption/kms_encryption_plugin' require_relative '../plugins/secrets_manager_plugin' @@ -46,6 +47,7 @@ class PluginManager 'gdb_failover' => Plugins::Gdb::GdbFailoverPlugin, 'iam' => Plugins::IamAuthPlugin, 'federated_auth' => Plugins::FederatedAuth::FederatedAuthPlugin, + 'okta' => Plugins::FederatedAuth::OktaAuthPlugin, 'initial_connection' => Plugins::InitialConnectionStrategyPlugin, 'kms_encryption' => Plugins::KmsEncryptionPlugin, 'secrets_manager' => Plugins::SecretsManagerPlugin @@ -63,13 +65,15 @@ class PluginManager Plugins::IamAuthPlugin => 1800, Plugins::SecretsManagerPlugin => 1900, Plugins::FederatedAuth::FederatedAuthPlugin => 2000, + Plugins::FederatedAuth::OktaAuthPlugin => 2010, Plugins::KmsEncryptionPlugin => 2050 } # Plugins that authenticate by minting an RDS IAM token into the shared IAM token cache. IAM_TOKEN_PLUGIN_CLASSES = [ Plugins::IamAuthPlugin, - Plugins::FederatedAuth::FederatedAuthPlugin + Plugins::FederatedAuth::FederatedAuthPlugin, + Plugins::FederatedAuth::OktaAuthPlugin ].freeze class << self @@ -200,7 +204,7 @@ def load_plugins(service_container) end def ensure_single_auth_plugin(plugin_code_list) - auth_plugins_used = plugin_code_list & %w[iam secrets_manager federated_auth].freeze + auth_plugins_used = plugin_code_list & %w[iam secrets_manager federated_auth okta].freeze return unless auth_plugins_used.length > 1 diff --git a/spec/unit/plugins/federated_auth/federated_auth_plugin_spec.rb b/spec/unit/plugins/federated_auth/federated_auth_plugin_spec.rb index d093e53..32cbb69 100644 --- a/spec/unit/plugins/federated_auth/federated_auth_plugin_spec.rb +++ b/spec/unit/plugins/federated_auth/federated_auth_plugin_spec.rb @@ -232,12 +232,4 @@ def fetch_options(overrides = {}) end.to raise_error(AwsAdvancedRubyDriverWrapper::Errors::PluginConflictError) end end - - describe 'default idp_name' do - it 'defaults the identity provider name to adfs' do - expect( - AwsAdvancedRubyDriverWrapper::PropertyDefinition::IDP_NAME.get(Concurrent::Map.new) - ).to eq('adfs') - end - end end diff --git a/spec/unit/plugins/federated_auth/okta_auth_plugin_spec.rb b/spec/unit/plugins/federated_auth/okta_auth_plugin_spec.rb new file mode 100644 index 0000000..2b427e4 --- /dev/null +++ b/spec/unit/plugins/federated_auth/okta_auth_plugin_spec.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ---------------------------------------------------------------------------- +# Specs for the Okta-specific half of OktaAuthPlugin. +# +# The shared SAML connect flow (caching, STS exchange, token minting, retry, +# validation) is covered once against the base in base_saml_auth_plugin_spec.rb. +# This file covers what is unique to Okta: authenticating to Okta's /authn API +# for a one-time session token, redeeming it at the AWS app's SSO SAML endpoint, +# and extracting the assertion - i.e. `.fetch_saml_assertion` and `.session_token` +# - plus the two config seams Okta overrides (app_id is required, and app_id is +# threaded into the IdP flow). SamlUtils.http_request is stubbed so no network +# call happens. +# ---------------------------------------------------------------------------- + +require_relative '../../../spec_helper' +require 'net/http' +require 'aws-sdk-rds' +require 'aws_advanced_ruby_driver_wrapper/plugins/federated_auth/okta_auth_plugin' +require 'aws_advanced_ruby_driver_wrapper/host/host_info' +require 'aws_advanced_ruby_driver_wrapper/utils/iam_auth_utils' +require 'aws_advanced_ruby_driver_wrapper/utils/saml_utils' +require 'aws_advanced_ruby_driver_wrapper/errors' + +RSpec.describe AwsAdvancedRubyDriverWrapper::Plugins::FederatedAuth::OktaAuthPlugin do + OKTA_SAML_UTILS = AwsAdvancedRubyDriverWrapper::Utils::SamlUtils + OKTA_SAML_ERROR = AwsAdvancedRubyDriverWrapper::Errors::SamlAuthError + + # A stand-in Net::HTTPResponse: real enough for the `is_a?(Net::HTTPSuccess)` + # check the plugin performs, without an actual HTTP round-trip. + class OktaFakeHttpResponse + attr_reader :body, :code, :message + + def initialize(body: '', code: '200', message: 'OK', success: true) + @body = body + @code = code + @message = message + @success = success + end + + def is_a?(klass) + return @success if klass == Net::HTTPSuccess + + super + end + end + + OKTA_ENDPOINT = 'example.okta.com' + OKTA_APP_ID = 'okta-aws-app-id' + OKTA_SESSION_TOKEN = 'one-time-session-token' + + AUTHN_URL = "https://#{OKTA_ENDPOINT}/api/v1/authn".freeze + SAML_URL = + "https://#{OKTA_ENDPOINT}/app/amazon_aws/#{OKTA_APP_ID}/sso/saml?onetimetoken=#{OKTA_SESSION_TOKEN}".freeze + + SAML_PAGE = <<~HTML + +
+ +
+ + HTML + + def fetch_options(overrides = {}) + { + idp_endpoint: OKTA_ENDPOINT, + app_id: OKTA_APP_ID, + idp_username: 'jimbob@example.com', + idp_password: 'idpSecret', + ssl_insecure: false, + socket_timeout_sec: 60.0, + connect_timeout_sec: 60.0 + }.merge(overrides) + end + + def authn_response(token: OKTA_SESSION_TOKEN) + OktaFakeHttpResponse.new(body: JSON.generate(sessionToken: token)) + end + + describe '.fetch_saml_assertion' do + it 'authenticates for a session token, redeems it at the SSO SAML endpoint, and extracts the assertion' do + requests = [] + allow(OKTA_SAML_UTILS).to receive(:http_request) do |method:, url:, body: nil, **_rest| + requests << { method:, url:, body: } + url == AUTHN_URL ? authn_response : OktaFakeHttpResponse.new(body: SAML_PAGE) + end + + assertion = described_class.fetch_saml_assertion(**fetch_options) + + expect(assertion).to eq('BASE64+ASSERTION==') + authn = requests.find { |r| r[:url] == AUTHN_URL } + expect(authn[:method]).to eq(:post) + expect(JSON.parse(authn[:body])).to eq('username' => 'jimbob@example.com', 'password' => 'idpSecret') + expect(requests.map { |r| r[:url] }).to include(SAML_URL) + end + + it 'raises SamlAuthError when the SSO SAML request fails' do + allow(OKTA_SAML_UTILS).to receive(:http_request) do |url:, **_rest| + url == AUTHN_URL ? authn_response : OktaFakeHttpResponse.new(code: '502', message: 'Bad Gateway', success: false) + end + + expect { described_class.fetch_saml_assertion(**fetch_options) } + .to raise_error(OKTA_SAML_ERROR, /SAML request failed.*502/) + end + + it 'raises SamlAuthError when the SAML response has no SAMLResponse input' do + allow(OKTA_SAML_UTILS).to receive(:http_request) do |url:, **_rest| + url == AUTHN_URL ? authn_response : OktaFakeHttpResponse.new(body: 'no assertion') + end + + expect { described_class.fetch_saml_assertion(**fetch_options) } + .to raise_error(OKTA_SAML_ERROR, /invalid SAML response/) + end + end + + describe '.session_token' do + let(:http) do + lambda do |method, url, body: nil, headers: {}| + OKTA_SAML_UTILS.http_request(method:, url:, body:, headers:, ssl_insecure: false, + open_timeout_sec: 1, read_timeout_sec: 1) + end + end + + it 'returns the sessionToken from the /authn JSON response' do + allow(OKTA_SAML_UTILS).to receive(:http_request).and_return(authn_response(token: 'tok-123')) + + token = described_class.session_token(http, OKTA_ENDPOINT, 'user', 'pw') + + expect(token).to eq('tok-123') + end + + it 'raises SamlAuthError when the /authn request fails' do + allow(OKTA_SAML_UTILS).to receive(:http_request) + .and_return(OktaFakeHttpResponse.new(code: '401', message: 'Unauthorized', success: false)) + + expect { described_class.session_token(http, OKTA_ENDPOINT, 'user', 'pw') } + .to raise_error(OKTA_SAML_ERROR, /session token request failed.*401/) + end + + it 'raises SamlAuthError when the response carries no session token, surfacing the Okta status' do + allow(OKTA_SAML_UTILS).to receive(:http_request) + .and_return(OktaFakeHttpResponse.new(body: JSON.generate(status: 'MFA_REQUIRED'))) + + expect { described_class.session_token(http, OKTA_ENDPOINT, 'user', 'pw') } + .to raise_error(OKTA_SAML_ERROR, /did not return a session token \(status: MFA_REQUIRED\)/) + end + + it 'raises SamlAuthError when the response body is not valid JSON' do + allow(OKTA_SAML_UTILS).to receive(:http_request) + .and_return(OktaFakeHttpResponse.new(body: 'not json')) + + expect { described_class.session_token(http, OKTA_ENDPOINT, 'user', 'pw') } + .to raise_error(OKTA_SAML_ERROR, /did not return a session token/) + end + end + + # Okta-specific config seams, verified through a real connect so that app_id is + # shown to flow from wrapper_props into validation and into the IdP fetch. + describe 'app_id configuration' do + let(:mock_storage_service) { instance_double(AwsAdvancedRubyDriverWrapper::Utils::Storage::StorageService) } + let(:mock_db_dialect) { double('DbDialect', default_port: 5432) } + let(:mock_driver_dialect) { double('DriverDialect', user_property_key: :user) } + let(:mock_dialect_service) do + double('DialectService', db_dialect: mock_db_dialect, login_error?: false, driver_dialect: mock_driver_dialect) + end + let(:mock_service_container) do + double('ServiceContainer', storage_service: mock_storage_service, dialect_service: mock_dialect_service) + end + + def wrapper_props_map(hash) + map = Concurrent::Map.new + hash.each { |k, v| map[k] = v } + map + end + + def base_props + { + db_user: 'iamUser', + idp_endpoint: OKTA_ENDPOINT, + app_id: OKTA_APP_ID, + iam_role_arn: 'arn:aws:iam::123456789012:role/example_iam_role', + iam_idp_arn: 'arn:aws:iam::123456789012:saml-provider/example', + iam_region: 'us-east-2' + } + end + + def build_plugin(overrides = {}) + described_class.new(mock_service_container, wrapper_props_map(base_props.merge(overrides))) + end + + def pg_host_info + AwsAdvancedRubyDriverWrapper::Host::HostInfo.new( + host: 'pg.testdb.us-east-2.rds.amazonaws.com', + port: AwsAdvancedRubyDriverWrapper::Host::HostInfo::NO_PORT + ) + end + + before do + allow(mock_storage_service).to receive(:register) + allow(mock_storage_service).to receive(:get).and_return(nil) + allow(mock_storage_service).to receive(:set) + allow(described_class).to receive(:credentials_from_saml).and_return(Aws::Credentials.new('AKID', 'SECRET')) + allow(Aws::RDS::AuthTokenGenerator).to receive(:new) + .and_return(instance_double(Aws::RDS::AuthTokenGenerator, auth_token: 'token')) + end + + it 'raises SamlAuthError when app_id is missing' do + expect do + build_plugin(app_id: nil).connect(pg_host_info, {}, true, -> {}) + end.to raise_error(OKTA_SAML_ERROR, /app_id/) + end + + it 'threads app_id from wrapper_props into the SAML assertion fetch' do + captured_app_id = nil + allow(described_class).to receive(:fetch_saml_assertion) do |app_id:, **_rest| + captured_app_id = app_id + 'fake-assertion' + end + + begin + build_plugin.connect(pg_host_info, {}, true, -> { raise StandardError, 'simulated' }) + rescue StandardError + nil + end + + expect(captured_app_id).to eq(OKTA_APP_ID) + end + end + + describe 'PluginManager registration' do + before { require 'aws_advanced_ruby_driver_wrapper/services/plugin_manager' } + + it "is registered under the code 'okta'" do + plugin_classes = AwsAdvancedRubyDriverWrapper::Services::PluginManager.plugin_classes + expect(plugin_classes['okta']).to eq(described_class) + end + + it 'cannot be combined with another authentication plugin' do + wrapper_props = Concurrent::Map.new + wrapper_props[:wrapper_plugins] = 'okta,iam' + connection_service = double('ConnectionService', wrapper_props:) + container = double('ServiceContainer', connection_service:) + + expect do + AwsAdvancedRubyDriverWrapper::Services::PluginManager.new(container) + end.to raise_error(AwsAdvancedRubyDriverWrapper::Errors::PluginConflictError) + end + end +end