I have a Ruby application using the AWS SDK Ruby v3, and recent I've added support for using SSO profiles instead of static "key ID + secret" configuration.
The new setup works well for a while, until the token "expires" and I start getting Aws::Errors::InvalidSSOToken
exceptions, at which point the user needs to manually run the CLI's aws sso login
to get a browser login screen.
I would have liked to skip the manual AWS CLI running step - if the SDK can directly do the aws sso login
step (with the correct profile).
I can probably do exec
with the correct arguments - but I would like to do it "the SDK way".
This is my currently complete (unless I find bugs and/or people suggest changes) and working implementation of Ruby code that renews the SSO token without using the AWS CLI, and in a way that is compatible with the AWS Ruby SDK version 3 (as of this writing). It doesn't auto-launches the user's browser, but that is a good thing because the Python browsers
extension has a few bugs...
Kudus to @tsal-troser that, at his own answer, pointed at the Python example that was used to create this sample code, and @2ps that wrote that Python code.
require 'aws-sdk-core'
class SSOConfig
class Error < StandardError
end
def initialize profile = nil
profile = 'default' if profile.to_s.empty?
@profile = profile
raise "No AWS config file found!" unless File.exists? "#{ENV['HOME']}/.aws/config"
@config = File.readlines("#{ENV['HOME']}/.aws/config").collect(&:chomp)
@profile_config = @config.select do |l|
(l =~ /^\[profile #{@profile}\]$/ .. l =~ /^(?!\[profile #{@profile}\])(\[|$)/) ? true : false
end[...-1]
raise Error, "Failed to find session for profile #{profile!}" if session.to_s.empty?
@session_config = @config.select do |l|
(l =~ /^\[sso-session '?#{session}'?\]$/ .. l =~ /^(?!\[sso-session '?#{session}'?\])(\[|$)/) ? true : false
end[...-1]
raise Error, "Failed to find SSO session configuration!" if @session_config.empty?
end
def session
return @sdk_session unless @sdk_session.nil?
@sdk_session = @profile_config.select do |l|
l =~ /^sso_session/
end.collect do |l|
(l.split /\s*=\s*/).last
end.first
end
def start_url
return @sdk_start_url unless @sdk_start_url.nil?
@sdk_start_url = @session_config.select do |l|
l =~ /^sso_start_url/
end.collect do |l|
(l.split /\s*=\s*/).last
end.first
end
def account_id
return @sdk_account_id unless @sdk_account_id.nil?
@sdk_account_id = @profile_config.select do |l|
l =~ /^sso_account_id/
end.collect do |l|
(l.split /\s*=\s*/).last
end.first
end
def region
'us-east-1' # can we use other regions?
end
def client_name
'SSOClientName'
end
def try_renew_token
puts "SSO token has expired! Trying to re-authorize SSO session..."
oidc_client = Aws::SSOOIDC::Client.new(region: self.region)
creds = oidc_client.register_client(client_name: self.client_name, client_type: 'public')
auth = oidc_client.start_device_authorization(
client_id: creds.client_id,
client_secret: creds.client_secret,
start_url: self.start_url)
puts "Verification code: #{auth.user_code}"
puts "Open your browser with this url: #{auth.verification_uri_complete}"
puts "When complete, press ENTER here"
STDIN.getch; puts ""
token = oidc_client.create_token(
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: auth.device_code,
client_id: creds.client_id,
client_secret: creds.client_secret)
sso_client = Aws::SSO::Client.new(region: self.region)
account_roles = sso_client.list_account_roles(
access_token: token.access_token,
account_id: self.account_id)
role = account_roles.role_list.first
role_creds = sso_client.get_role_credentials(
role_name: role.role_name,
account_id: role.account_id,
access_token: token.access_token)
# update the global config credentials, in case someone creates a default client
# or actually uses the global credentials
Aws.config[:credentials] = Class.new do
include Aws::CredentialProvider
def initialize creds
@creds = creds
end
def credentials
Aws::Credentials.new(@creds.access_key_id, @creds.secret_access_key, @creds.session_token)
end
end.new(role_creds.role_credentials)
# hack SSOTokenProvider to update the token on disk
# the real SSOTokenProvider will check that file first before trying to refresh
Class.new(Aws::SSOTokenProvider) do
def refresh
# This is called by SSOTokenProvider.initialize and will fail with a stale token file
# so just ignore it in our hack
end
end.new(sso_session: self.session, sso_region: self.region).send :update_token_cache, {
'startUrl' => self.start_url,
'region' => self.region,
'accessToken' => token.access_token,
'clientId' => creds.client_id,
'clientSecret' => creds.client_secret,
'expiresAt' => Time.at(Time.now.tv_sec + token.expires_in).utc,
'registrationExpiresAt' => Time.at(Time.now.tv_sec + token.expires_in).utc,
'refreshToken' => token.refresh_token,
}
true
end
end
Usage: when catching an Aws::Errors::InvalidSSOToken
, call SSOConfig.new("your-profile").try_renew_token
. After the call returns successfully, Aws.config[:credentials]
is loaded with a credential provider that will return the correct credentials, and also the token cache on disk has been updated - so existing clients should continue working by refreshing from the disk cache.
For example:
begin
# try to do some AWS work
rescue Aws::Errors::InvalidSSOToken => e
retry if SSOConfig.new("your-profile").try_renew_token
$stderr.puts "SSO token expired - cannot renew!"
end
SSOTokenProvider
and there is no way to manipulate it directly (or get SSOTokenProvider
to refresh it itself, once it has expired). So in order to update the disk cache, we both need to instantiate a purposefully broken SSOTokenProvider
(otherwise it won't instantiate) and then call its private methods directly. Fun! 😜CredentialProvider
for Aws.config[:credentials]
, so we use an anonymous class.false
.create_token
doesn't have a refresh token so SSOTokenProvider
can't refresh it automatically.