From 4f32ae976e32d916639cd0c21b407f5427ae97de Mon Sep 17 00:00:00 2001 From: Sophia Chu <112967780+sophia-bq@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:52:17 -0700 Subject: [PATCH 1/4] feat: okta --- .../plugins/blue_green/status_provider.rb | 5 +- .../federated_auth/okta_auth_plugin.rb | 103 +++++++ .../property_definition.rb | 1 + .../services/plugin_manager.rb | 8 +- .../federated_auth/okta_auth_plugin_spec.rb | 263 ++++++++++++++++++ 5 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/okta_auth_plugin.rb create mode 100644 spec/unit/plugins/federated_auth/okta_auth_plugin_spec.rb 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..d76e2b0 --- /dev/null +++ b/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/okta_auth_plugin.rb @@ -0,0 +1,103 @@ +# 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 Okta + # 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 + + token = begin + parsed = JSON.parse(response.body) + parsed['sessionToken'] if parsed.is_a?(Hash) + rescue JSON::ParserError + nil + end + raise Errors::SamlAuthError, 'OktaAuthPlugin: Okta did not return a session token' if token.nil? || token.empty? + + 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..9ade158 100644 --- a/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb +++ b/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb @@ -185,6 +185,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..7d73c48 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::Okta::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::Okta::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::Okta::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/okta_auth_plugin_spec.rb b/spec/unit/plugins/federated_auth/okta_auth_plugin_spec.rb new file mode 100644 index 0000000..069dc82 --- /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::Okta::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' 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/) + 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 From 07b3107483e187ad0f5ecfe6216dea82d629815d Mon Sep 17 00:00:00 2001 From: Sophia Chu <112967780+sophia-bq@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:40:46 -0700 Subject: [PATCH 2/4] docs: add okta to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3385cf7..06f0dbe 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 but against Okta is also available via the `okta` plugin code. ### :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)). From 83a5e9f153e56e5ff837413dd7a613ff16f65db4 Mon Sep 17 00:00:00 2001 From: Sophia Chu <112967780+sophia-bq@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:53:32 -0700 Subject: [PATCH 3/4] fix: apply review comments --- CHANGELOG.md | 2 +- .../plugins/federated_auth/okta_auth_plugin.rb | 18 +++++++++++++----- .../services/plugin_manager.rb | 6 +++--- .../federated_auth/okta_auth_plugin_spec.rb | 6 +++--- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f0dbe..5dc9c41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +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 but against Okta is also available via the `okta` plugin code. +- [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/federated_auth/okta_auth_plugin.rb b/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/okta_auth_plugin.rb index d76e2b0..15186ef 100644 --- 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 @@ -20,7 +20,7 @@ module AwsAdvancedRubyDriverWrapper module Plugins - module Okta + 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 @@ -77,13 +77,21 @@ def self.session_token(http, idp_endpoint, idp_username, idp_password) "OktaAuthPlugin: Okta session token request failed (#{response.code} #{response.message})" end - token = begin - parsed = JSON.parse(response.body) - parsed['sessionToken'] if parsed.is_a?(Hash) + parsed = begin + JSON.parse(response.body) rescue JSON::ParserError nil end - raise Errors::SamlAuthError, 'OktaAuthPlugin: Okta did not return a session token' if token.nil? || token.empty? + 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 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 7d73c48..9ca3287 100644 --- a/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb +++ b/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb @@ -47,7 +47,7 @@ class PluginManager 'gdb_failover' => Plugins::Gdb::GdbFailoverPlugin, 'iam' => Plugins::IamAuthPlugin, 'federated_auth' => Plugins::FederatedAuth::FederatedAuthPlugin, - 'okta' => Plugins::Okta::OktaAuthPlugin, + 'okta' => Plugins::FederatedAuth::OktaAuthPlugin, 'initial_connection' => Plugins::InitialConnectionStrategyPlugin, 'kms_encryption' => Plugins::KmsEncryptionPlugin, 'secrets_manager' => Plugins::SecretsManagerPlugin @@ -65,7 +65,7 @@ class PluginManager Plugins::IamAuthPlugin => 1800, Plugins::SecretsManagerPlugin => 1900, Plugins::FederatedAuth::FederatedAuthPlugin => 2000, - Plugins::Okta::OktaAuthPlugin => 2010, + Plugins::FederatedAuth::OktaAuthPlugin => 2010, Plugins::KmsEncryptionPlugin => 2050 } @@ -73,7 +73,7 @@ class PluginManager IAM_TOKEN_PLUGIN_CLASSES = [ Plugins::IamAuthPlugin, Plugins::FederatedAuth::FederatedAuthPlugin, - Plugins::Okta::OktaAuthPlugin + Plugins::FederatedAuth::OktaAuthPlugin ].freeze class << self diff --git a/spec/unit/plugins/federated_auth/okta_auth_plugin_spec.rb b/spec/unit/plugins/federated_auth/okta_auth_plugin_spec.rb index 069dc82..2b427e4 100644 --- a/spec/unit/plugins/federated_auth/okta_auth_plugin_spec.rb +++ b/spec/unit/plugins/federated_auth/okta_auth_plugin_spec.rb @@ -36,7 +36,7 @@ require 'aws_advanced_ruby_driver_wrapper/utils/saml_utils' require 'aws_advanced_ruby_driver_wrapper/errors' -RSpec.describe AwsAdvancedRubyDriverWrapper::Plugins::Okta::OktaAuthPlugin do +RSpec.describe AwsAdvancedRubyDriverWrapper::Plugins::FederatedAuth::OktaAuthPlugin do OKTA_SAML_UTILS = AwsAdvancedRubyDriverWrapper::Utils::SamlUtils OKTA_SAML_ERROR = AwsAdvancedRubyDriverWrapper::Errors::SamlAuthError @@ -151,12 +151,12 @@ def authn_response(token: OKTA_SESSION_TOKEN) .to raise_error(OKTA_SAML_ERROR, /session token request failed.*401/) end - it 'raises SamlAuthError when the response carries no session token' do + 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/) + .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 From 22cadebab16b7f3132023dca6a62c07749932583 Mon Sep 17 00:00:00 2001 From: Sophia Chu <112967780+sophia-bq@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:21:32 -0700 Subject: [PATCH 4/4] fix: remove unused idp_name --- .../property_definition.rb | 2 -- .../plugins/federated_auth/federated_auth_plugin_spec.rb | 8 -------- 2 files changed, 10 deletions(-) diff --git a/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb b/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb index 9ade158..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) 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