Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

## [1.0.1] - 2026-10-07

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = /<input[^>]*\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. &#x3d; / &#x2b;); 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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading