From 2159cd1b367394a2f1b870a0d551bd7b0567643a Mon Sep 17 00:00:00 2001 From: Eneko Alonso Date: Mon, 7 Dec 2020 12:02:25 -0800 Subject: [PATCH] Add support for profile configuration files and STS AssumeRole for AIM roles (#408) * Allow loading credentials from profile and default * WIP: Load source_profile when available * WIP: ConfigFileLoader * WIP: ConfigFileLoader * WIP: AWSConfigFileCredentialProvider * WIP: AWSConfigFileCredentialProvider * Finish implementation of ConfigFileLoader and ConfigFileCredentialProvider * Use RotatingCredentialProvider and shutdown STS client after getting credentials * Move sharedCredentials logic to ConfigFileCredentialProvider * Move FileIO logic to ConfigFileLoader * Formatting cleanup * Make config file path non-optional * Fixes and unit tests * PR feedback * Fix sanity issues * More cleanup * Add test for STSAssumeRole via ConfigFileCredentialsProvider * Update test ordering * Sanity checks * Sanity checks * PR feedback * Further clarify logic for different file credential configurations * Fix syntax * Fix UUID import * Fix issues * Fix AWSShape --- Sources/INIParser/INIParser.swift | 2 +- .../ConfigFileCredentialProvider.swift | 182 ++--- .../Credential/ConfigFileLoader.swift | 318 ++++++++ .../Credential/CredentialProvider.swift | 12 +- .../Credential/CredentialProviderError.swift | 4 + .../SotoCore/Credential/STSAssumeRole.swift | 4 +- Tests/INIParserTests/INIParserTests.swift | 2 + .../ConfigFileCredentialProviderTests.swift | 334 +++----- .../Credential/ConfigFileLoaderTests.swift | 731 ++++++++++++++++++ ...ntimeSelectorCredentialProviderTests.swift | 2 +- .../Credential/STSAssumeRoleTests.swift | 104 +++ 11 files changed, 1355 insertions(+), 340 deletions(-) create mode 100644 Sources/SotoCore/Credential/ConfigFileLoader.swift create mode 100644 Tests/SotoCoreTests/Credential/ConfigFileLoaderTests.swift create mode 100644 Tests/SotoCoreTests/Credential/STSAssumeRoleTests.swift diff --git a/Sources/INIParser/INIParser.swift b/Sources/INIParser/INIParser.swift index 93d58b34f..6b22fa926 100644 --- a/Sources/INIParser/INIParser.swift +++ b/Sources/INIParser/INIParser.swift @@ -47,7 +47,7 @@ public final class INIParser { for c in line { switch c { case " ", "\t": - if state == .SingleQuotation || state == .DoubleQuotation { + if state == .SingleQuotation || state == .DoubleQuotation || state == .Title { cache.append(c) } case "[": diff --git a/Sources/SotoCore/Credential/ConfigFileCredentialProvider.swift b/Sources/SotoCore/Credential/ConfigFileCredentialProvider.swift index 039841ac7..2c815bd38 100644 --- a/Sources/SotoCore/Credential/ConfigFileCredentialProvider.swift +++ b/Sources/SotoCore/Credential/ConfigFileCredentialProvider.swift @@ -12,30 +12,12 @@ // //===----------------------------------------------------------------------===// -import INIParser import Logging import NIO import NIOConcurrencyHelpers import SotoSignerV4 -#if os(Linux) -import Glibc -#else -import Foundation.NSString -#endif - -class AWSConfigFileCredentialProvider: CredentialProviderSelector { - /// Errors occurring when initializing a FileCredential - /// - /// - missingProfile: If the profile requested was not found - /// - missingAccessKeyId: If the access key ID was not found - /// - missingSecretAccessKey: If the secret access key was not found - enum ConfigFileError: Error, Equatable { - case invalidCredentialFileSyntax - case missingProfile(String) - case missingAccessKeyId - case missingSecretAccessKey - } +class ConfigFileCredentialProvider: CredentialProviderSelector { /// promise to find a credential provider let startupPromise: EventLoopPromise /// lock for access to _internalProvider. @@ -43,113 +25,85 @@ class AWSConfigFileCredentialProvider: CredentialProviderSelector { /// internal version of internal provider. Should access this through `internalProvider` var _internalProvider: CredentialProvider? - init(credentialsFilePath: String, profile: String? = nil, context: CredentialProviderFactory.Context) { + init( + credentialsFilePath: String, + configFilePath: String, + profile: String? = nil, + context: CredentialProviderFactory.Context, + endpoint: String? = nil + ) { self.startupPromise = context.eventLoop.makePromise(of: CredentialProvider.self) self.startupPromise.futureResult.whenSuccess { result in self.internalProvider = result } - self.fromSharedCredentials(credentialsFilePath: credentialsFilePath, profile: profile, on: context.eventLoop) - } - - /// Load credentials from the aws cli config path `~/.aws/credentials` - /// - /// - Parameters: - /// - credentialsFilePath: credential config file - /// - profile: profile to use - /// - eventLoop: eventLoop to run everything on - func fromSharedCredentials( - credentialsFilePath: String, - profile: String?, - on eventLoop: EventLoop - ) { - let profile = profile ?? Environment["AWS_PROFILE"] ?? "default" - let threadPool = NIOThreadPool(numberOfThreads: 1) - threadPool.start() - let fileIO = NonBlockingFileIO(threadPool: threadPool) - Self.getSharedCredentialsFromDisk(credentialsFilePath: credentialsFilePath, profile: profile, on: eventLoop, using: fileIO) - .always { _ in - // shutdown the threadpool async - threadPool.shutdownGracefully { _ in } - } + let profile = profile ?? Environment["AWS_PROFILE"] ?? ConfigFile.defaultProfile + Self.credentialProvider(from: credentialsFilePath, configFilePath: configFilePath, for: profile, context: context, endpoint: endpoint) .cascade(to: self.startupPromise) } - static func getSharedCredentialsFromDisk( - credentialsFilePath: String, - profile: String, - on eventLoop: EventLoop, - using fileIO: NonBlockingFileIO + /// Credential provider from shared credentials and profile configuration files + /// + /// - Parameters: + /// - credentialsByteBuffer: contents of AWS shared credentials file (usually `~/.aws/credentials`) + /// - configByteBuffer: contents of AWS profile configuration file (usually `~/.aws/config`) + /// - profile: named profile to load (usually `default`) + /// - context: credential provider factory context + /// - endpoint: STS Assume role endpoint (for unit testing) + /// - Returns: Credential Provider (StaticCredentials or STSAssumeRole) + static func credentialProvider( + from credentialsFilePath: String, + configFilePath: String, + for profile: String, + context: CredentialProviderFactory.Context, + endpoint: String? ) -> EventLoopFuture { - let filePath = Self.expandTildeInFilePath(credentialsFilePath) - - return fileIO.openFile(path: filePath, eventLoop: eventLoop) - .flatMap { handle, region in - fileIO.read(fileRegion: region, allocator: ByteBufferAllocator(), eventLoop: eventLoop).map { ($0, handle) } - } - .flatMapThrowing { byteBuffer, handle in - try handle.close() - return try Self.sharedCredentials(from: byteBuffer, for: profile) - } - } - - static func sharedCredentials(from byteBuffer: ByteBuffer, for profile: String) throws -> StaticCredential { - let string = byteBuffer.getString(at: 0, length: byteBuffer.readableBytes)! - var parser: INIParser - do { - parser = try INIParser(string) - } catch INIParser.Error.invalidSyntax { - throw ConfigFileError.invalidCredentialFileSyntax - } - - guard let config = parser.sections[profile] else { - throw ConfigFileError.missingProfile(profile) - } - - guard let accessKeyId = config["aws_access_key_id"] else { - throw ConfigFileError.missingAccessKeyId - } - - guard let secretAccessKey = config["aws_secret_access_key"] else { - throw ConfigFileError.missingSecretAccessKey + return ConfigFileLoader.loadSharedCredentials( + credentialsFilePath: credentialsFilePath, + configFilePath: configFilePath, + profile: profile, + context: context + ) + .flatMapThrowing { sharedCredentials in + return try credentialProvider(from: sharedCredentials, context: context, endpoint: endpoint) } - - let sessionToken = config["aws_session_token"] - - return StaticCredential(accessKeyId: accessKeyId, secretAccessKey: secretAccessKey, sessionToken: sessionToken) } - static func expandTildeInFilePath(_ filePath: String) -> String { - #if os(Linux) - // We don't want to add more dependencies on Foundation than needed. - // For this reason we get the expanded filePath on Linux from libc. - // Since `wordexp` and `wordfree` are not available on iOS we stay - // with NSString on Darwin. - return filePath.withCString { (ptr) -> String in - var wexp = wordexp_t() - guard wordexp(ptr, &wexp, 0) == 0, let we_wordv = wexp.we_wordv else { - return filePath - } - defer { - wordfree(&wexp) - } - - guard let resolved = we_wordv[0], let pth = String(cString: resolved, encoding: .utf8) else { - return filePath - } - - return pth - } - #elseif os(macOS) - // can not use wordexp on macOS because for sandboxed application wexp.we_wordv == nil - guard let home = getpwuid(getuid())?.pointee.pw_dir, - let homePath = String(cString: home, encoding: .utf8) - else { - return filePath + /// Generate credential provider based on shared credentials and profile configuration + /// + /// Credentials file settings have precedence over profile configuration settings + /// https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html#cli-configure-quickstart-precedence + /// + /// - Parameters: + /// - sharedCredentials: combined credentials loaded from disl (usually `~/.aws/credentials` and `~/.aws/config`) + /// - context: credential provider factory context + /// - endpoint: STS Assume role endpoint (for unit testing) + /// - Returns: Credential Provider (StaticCredentials or STSAssumeRole) + static func credentialProvider( + from sharedCredentials: ConfigFileLoader.SharedCredentials, + context: CredentialProviderFactory.Context, + endpoint: String? + ) throws -> CredentialProvider { + switch sharedCredentials { + case .staticCredential(let staticCredential): + return staticCredential + case .assumeRole(let roleArn, let sessionName, let region, let sourceCredential): + let request = STSAssumeRoleRequest(roleArn: roleArn, roleSessionName: sessionName) + let provider = CredentialProviderFactory.static( + accessKeyId: sourceCredential.accessKeyId, + secretAccessKey: sourceCredential.secretAccessKey, + sessionToken: sourceCredential.sessionToken + ) + let region = region ?? .useast1 + return STSAssumeRoleCredentialProvider( + request: request, + credentialProvider: provider, + region: region, + httpClient: context.httpClient, + endpoint: endpoint + ) + case .credentialSource: + throw CredentialProviderError.notSupported } - return filePath.starts(with: "~") ? homePath + filePath.dropFirst() : filePath - #else - return NSString(string: filePath).expandingTildeInPath - #endif } } diff --git a/Sources/SotoCore/Credential/ConfigFileLoader.swift b/Sources/SotoCore/Credential/ConfigFileLoader.swift new file mode 100644 index 000000000..d48ed11a3 --- /dev/null +++ b/Sources/SotoCore/Credential/ConfigFileLoader.swift @@ -0,0 +1,318 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Soto for AWS open source project +// +// Copyright (c) 2017-2020 the Soto project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Soto project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import struct Foundation.UUID +import INIParser +import Logging +import NIO +#if os(Linux) +import Glibc +#else +import Foundation.NSString +#endif + +public enum ConfigFile { + public static let defaultCredentialsPath = "~/.aws/credentials" + public static let defaultProfileConfigPath = "~/.aws/config" + public static let defaultProfile = "default" +} + +/// Load settings from AWS credentials and profile configuration files +/// https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html +enum ConfigFileLoader { + /// Specific type of credentials loaded from disk + enum SharedCredentials: Equatable { + case staticCredential(credential: StaticCredential) + case assumeRole(roleArn: String, sessionName: String, region: Region?, sourceCredential: StaticCredential) + case credentialSource(roleArn: String, source: CredentialSource) + } + + /// Credentials file – The credentials and config file are updated when you run the command aws configure. The credentials file is located + /// at `~/.aws/credentials` on Linux or macOS, or at C:\Users\USERNAME\.aws\credentials on Windows. This file can contain the credential + /// details for the default profile and any named profiles. + struct ProfileCredentials: Equatable { + let accessKey: String? + let secretAccessKey: String? + let sessionToken: String? + let roleArn: String? + let roleSessionName: String? + let sourceProfile: String? + let credentialSource: CredentialSource? + } + + /// The credentials and config file are updated when you run the command aws configure. The config file is located at `~/.aws/config` on Linux + /// or macOS, or at C:\Users\USERNAME\.aws\config on Windows. This file contains the configuration settings for the default profile and any named profiles. + struct ProfileConfig: Equatable { + let region: Region? + let roleArn: String? + let roleSessionName: String? + let sourceProfile: String? + let credentialSource: CredentialSource? + } + + /// Profile credential source `credential_source` + /// + /// Used within Amazon EC2 instances or EC2 containers to specify where the AWS CLI can find credentials to use to assume the role you + /// specified with the `role_arn` parameter. You cannot specify both `source_profile` and `credential_source` in the same profile. + enum CredentialSource: String, Equatable { + case environment = "Environment" + case ec2Instance = "Ec2InstanceMetadata" + case ecsContainer = "EcsContainer" + } + + /// Errors occurring when loading credentials and profile configuration + /// - invalidCredentialFile: If credentials could not be loaded from disk because of invalid configuration or syntax + /// - missingProfile: If the profile requested was not found + /// - missingAccessKeyId: If the access key ID was not found + /// - missingSecretAccessKey: If the secret access key was not found + enum ConfigFileError: Error, Equatable { + case invalidCredentialFile + case missingProfile(String) + case missingAccessKeyId + case missingSecretAccessKey + } + + // MARK: - File IO + + /// Load credentials from disk + /// - Parameters: + /// - credentialsFilePath: file path for AWS credentials file + /// - configFilePath: file path for AWS config file + /// - profile: named profile to load + /// - context: credential provider factory context + /// - Returns: Promise of SharedCredentials + static func loadSharedCredentials( + credentialsFilePath: String, + configFilePath: String, + profile: String, + context: CredentialProviderFactory.Context + ) -> EventLoopFuture { + let threadPool = NIOThreadPool(numberOfThreads: 1) + threadPool.start() + let fileIO = NonBlockingFileIO(threadPool: threadPool) + + // Load credentials file + return self.loadFile(path: credentialsFilePath, on: context.eventLoop, using: fileIO) + .flatMap { credentialsByteBuffer in + // Load profile config file + return loadFile(path: configFilePath, on: context.eventLoop, using: fileIO) + .map { + (credentialsByteBuffer, $0) + } + .flatMapError { _ in + // Recover from error if profile config file does not exist + context.eventLoop.makeSucceededFuture((credentialsByteBuffer, nil)) + } + } + .flatMapErrorThrowing { _ in + // Throw `.noProvider` error if credential file cannot be loaded + throw CredentialProviderError.noProvider + } + .flatMapThrowing { credentialsByteBuffer, configByteBuffer in + return try parseSharedCredentials(from: credentialsByteBuffer, configByteBuffer: configByteBuffer, for: profile) + } + .always { _ in + // shutdown the threadpool async + threadPool.shutdownGracefully { _ in } + } + } + + /// Load a file from disk without blocking the current thread + /// - Parameters: + /// - path: path for the file to load + /// - eventLoop: event loop to run everything on + /// - fileIO: non-blocking file IO + /// - Returns: Event loop future with file contents in a byte-buffer + static func loadFile(path: String, on eventLoop: EventLoop, using fileIO: NonBlockingFileIO) -> EventLoopFuture { + let path = self.expandTildeInFilePath(path) + + return fileIO.openFile(path: path, eventLoop: eventLoop) + .flatMap { handle, region in + fileIO.read(fileRegion: region, allocator: ByteBufferAllocator(), eventLoop: eventLoop) + .map { + ($0, handle) + } + } + .flatMapThrowing { byteBuffer, handle in + try handle.close() + return byteBuffer + } + } + + // MARK: - Byte Buffer parsing (INIParser) + + /// Parse credentials from files (passed in as byte-buffers). + /// This method ensures credentials are valid according to AWS documentation. + /// + /// Credentials file settings have precedence over profile configuration settings. + /// https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html#cli-configure-quickstart-precedence + /// + /// - Parameters: + /// - credentialsBuffer: contents of AWS shared credentials file (usually `~/.aws/credentials`) + /// - configByteBuffer: contents of AWS profile configuration file (usually `~/.aws/config`) + /// - profile: named profile to load (optional) + /// - Returns: Parsed SharedCredentials + static func parseSharedCredentials(from credentialsByteBuffer: ByteBuffer, configByteBuffer: ByteBuffer?, for profile: String) throws -> SharedCredentials { + let config = try configByteBuffer.flatMap { try parseProfileConfig(from: $0, for: profile) } + let credentials = try parseCredentials(from: credentialsByteBuffer, for: profile, sourceProfile: config?.sourceProfile) + + // If `role_arn` is defined, check for source profile or credential source + if let roleArn = credentials.roleArn ?? config?.roleArn { + // If `source_profile` is defined, temporary credentials must be loaded via STS AssumeRole operation + if let _ = credentials.sourceProfile ?? config?.sourceProfile { + guard let accessKey = credentials.accessKey else { + throw ConfigFileError.missingAccessKeyId + } + guard let secretAccessKey = credentials.secretAccessKey else { + throw ConfigFileError.missingSecretAccessKey + } + let sessionName = credentials.roleSessionName ?? config?.roleSessionName ?? UUID().uuidString + let region = config?.region ?? .useast1 + let sourceCredential = StaticCredential(accessKeyId: accessKey, secretAccessKey: secretAccessKey, sessionToken: credentials.sessionToken) + return .assumeRole(roleArn: roleArn, sessionName: sessionName, region: region, sourceCredential: sourceCredential) + } + // If `credental_source` is defined, temporary credentials must be loaded from source + else if let credentialSource = credentials.credentialSource ?? config?.credentialSource + { + return .credentialSource(roleArn: roleArn, source: credentialSource) + } + // Invalid configuration + throw ConfigFileError.invalidCredentialFile + } + + // Return static credentials + guard let accessKey = credentials.accessKey else { + throw ConfigFileError.missingAccessKeyId + } + guard let secretAccessKey = credentials.secretAccessKey else { + throw ConfigFileError.missingSecretAccessKey + } + let credential = StaticCredential(accessKeyId: accessKey, secretAccessKey: secretAccessKey, sessionToken: credentials.sessionToken) + return .staticCredential(credential: credential) + } + + /// Parse profile configuraton from a file (passed in as byte-buffer), usually `~/.aws/config` + /// + /// - Parameters: + /// - byteBuffer: contents of the file to parse + /// - profile: AWS named profile to load (usually `default`) + /// - Returns: Combined profile settings + static func parseProfileConfig(from byteBuffer: ByteBuffer, for profile: String) throws -> ProfileConfig? { + guard let content = byteBuffer.getString(at: 0, length: byteBuffer.readableBytes), + let parser = try? INIParser(content) + else { + throw ConfigFileError.invalidCredentialFile + } + + // The credentials file uses a different naming format than the CLI config file for named profiles. Include + // the prefix word "profile" only when configuring a named profile in the config file. Do not use the word + // profile when creating an entry in the credentials file. + // https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html + let loadedProfile = profile == ConfigFile.defaultProfile ? profile : "profile \(profile)" + + // Gracefully fail if there is no configuration for the given profile + guard let settings = parser.sections[loadedProfile] else { + return nil + } + + // All values are optional for profile configuration + return ProfileConfig( + region: settings["region"].flatMap(Region.init(awsRegionName:)), + roleArn: settings["role_arn"], + roleSessionName: settings["role_session_name"], + sourceProfile: settings["source_profile"], + credentialSource: settings["credential_source"].flatMap(CredentialSource.init(rawValue:)) + ) + } + + /// Parse profile credentials from a file (passed in as byte-buffer), usually `~/.aws/credentials` + /// + /// - Parameters: + /// - byteBuffer: contents of the file to parse + /// - profile: AWS named profile to load (usually `default`) + /// - sourceProfile: specifies a named profile with long-term credentials that the AWS CLI can use to assume a role that you specified with the `role_arn` parameter. + /// - Returns: Combined profile credentials + static func parseCredentials(from byteBuffer: ByteBuffer, for profile: String, sourceProfile: String?) throws -> ProfileCredentials { + guard let content = byteBuffer.getString(at: 0, length: byteBuffer.readableBytes), + let parser = try? INIParser(content) + else { + throw ConfigFileError.invalidCredentialFile + } + + guard let settings = parser.sections[profile] else { + throw ConfigFileError.missingProfile(profile) + } + + var accessKey = settings["aws_access_key_id"] + var secretAccessKey = settings["aws_secret_access_key"] + var sessionToken = settings["aws_session_token"] + + // If a source profile is indicated, load credentials for STS Assume Role operation. + // Credentials file settings have precedence over profile configuration settings. + if let sourceProfile = settings["source_profile"] ?? sourceProfile { + guard let sourceSettings = parser.sections[sourceProfile] else { + throw ConfigFileError.missingProfile(sourceProfile) + } + accessKey = sourceSettings["aws_access_key_id"] + secretAccessKey = sourceSettings["aws_secret_access_key"] + sessionToken = sourceSettings["aws_session_token"] + } + + return ProfileCredentials( + accessKey: accessKey, + secretAccessKey: secretAccessKey, + sessionToken: sessionToken, + roleArn: settings["role_arn"], + roleSessionName: settings["role_session_name"], + sourceProfile: sourceProfile ?? settings["source_profile"], + credentialSource: settings["credential_source"].flatMap(CredentialSource.init(rawValue:)) + ) + } + + // MARK: - Path Expansion + + static func expandTildeInFilePath(_ filePath: String) -> String { + #if os(Linux) + // We don't want to add more dependencies on Foundation than needed. + // For this reason we get the expanded filePath on Linux from libc. + // Since `wordexp` and `wordfree` are not available on iOS we stay + // with NSString on Darwin. + return filePath.withCString { (ptr) -> String in + var wexp = wordexp_t() + guard wordexp(ptr, &wexp, 0) == 0, let we_wordv = wexp.we_wordv else { + return filePath + } + defer { + wordfree(&wexp) + } + + guard let resolved = we_wordv[0], let pth = String(cString: resolved, encoding: .utf8) else { + return filePath + } + + return pth + } + #elseif os(macOS) + // can not use wordexp on macOS because for sandboxed application wexp.we_wordv == nil + guard let home = getpwuid(getuid())?.pointee.pw_dir, + let homePath = String(cString: home, encoding: .utf8) + else { + return filePath + } + return filePath.starts(with: "~") ? homePath + filePath.dropFirst() : filePath + #else + return NSString(string: filePath).expandingTildeInPath + #endif + } +} diff --git a/Sources/SotoCore/Credential/CredentialProvider.swift b/Sources/SotoCore/Credential/CredentialProvider.swift index a8ee43d12..149b20fc6 100644 --- a/Sources/SotoCore/Credential/CredentialProvider.swift +++ b/Sources/SotoCore/Credential/CredentialProvider.swift @@ -114,11 +114,15 @@ extension CredentialProviderFactory { } } - /// Use this method to load credentials from your aws cli credential file, normally located at `~/.aws/credentials` - public static func configFile(credentialsFilePath: String = "~/.aws/credentials", profile: String? = nil) -> CredentialProviderFactory { + /// Use this method to load credentials from your AWS cli credentials and optional profile configuration files, normally located at `~/.aws/credentials` and `~/.aws/config`. + public static func configFile( + credentialsFilePath: String = ConfigFile.defaultCredentialsPath, + configFilePath: String = ConfigFile.defaultProfileConfigPath, + profile: String? = nil + ) -> CredentialProviderFactory { return Self { context in - let provider = AWSConfigFileCredentialProvider(credentialsFilePath: credentialsFilePath, profile: profile, context: context) - return DeferredCredentialProvider(context: context, provider: provider) + let provider = ConfigFileCredentialProvider(credentialsFilePath: credentialsFilePath, configFilePath: configFilePath, profile: profile, context: context) + return RotatingCredentialProvider(context: context, provider: provider) } } diff --git a/Sources/SotoCore/Credential/CredentialProviderError.swift b/Sources/SotoCore/Credential/CredentialProviderError.swift index 281f2ec35..71e4b93db 100644 --- a/Sources/SotoCore/Credential/CredentialProviderError.swift +++ b/Sources/SotoCore/Credential/CredentialProviderError.swift @@ -15,11 +15,13 @@ public struct CredentialProviderError: Error, Equatable { enum _CredentialProviderError { case noProvider + case notSupported } let error: _CredentialProviderError public static var noProvider: CredentialProviderError { return .init(error: .noProvider) } + public static var notSupported: CredentialProviderError { return .init(error: .notSupported) } } extension CredentialProviderError: CustomStringConvertible { @@ -27,6 +29,8 @@ extension CredentialProviderError: CustomStringConvertible { switch self.error { case .noProvider: return "No credential provider found" + case .notSupported: + return "Credential method not supported" } } } diff --git a/Sources/SotoCore/Credential/STSAssumeRole.swift b/Sources/SotoCore/Credential/STSAssumeRole.swift index 7b805155d..bf4edce40 100644 --- a/Sources/SotoCore/Credential/STSAssumeRole.swift +++ b/Sources/SotoCore/Credential/STSAssumeRole.swift @@ -127,7 +127,9 @@ struct STSAssumeRoleCredentialProvider: CredentialProviderWithClient { func getCredential(on eventLoop: EventLoop, logger: Logger) -> EventLoopFuture { self.assumeRole(self.request, logger: logger, on: eventLoop) .flatMapThrowing { response in - guard let credentials = response.credentials else { throw CredentialProviderError.noProvider } + guard let credentials = response.credentials else { + throw CredentialProviderError.noProvider + } return credentials } } diff --git a/Tests/INIParserTests/INIParserTests.swift b/Tests/INIParserTests/INIParserTests.swift index cdba20ee4..9c0b56f3f 100644 --- a/Tests/INIParserTests/INIParserTests.swift +++ b/Tests/INIParserTests/INIParserTests.swift @@ -35,6 +35,7 @@ class INIParserTests: XCTestCase { 变量1 = 🇨🇳 ;使用utf8 变量2 = 加拿大。 [ 乱死了 ] + foo = bar """ var ini: INIParser? @@ -50,6 +51,7 @@ class INIParserTests: XCTestCase { XCTAssertEqual(ini?.sections["database"]?["file"] ?? "", "\"中文.dat \' \' \"") XCTAssertEqual(ini?.sections["汉化"]?["变量1"] ?? "", "🇨🇳") XCTAssertEqual(ini?.sections["汉化"]?["变量2"] ?? "", "加拿大。") + XCTAssertNotNil(ini?.sections[" 乱死了 "]) } static var allTests = [ diff --git a/Tests/SotoCoreTests/Credential/ConfigFileCredentialProviderTests.swift b/Tests/SotoCoreTests/Credential/ConfigFileCredentialProviderTests.swift index 3ac82c785..3fd66f1b5 100644 --- a/Tests/SotoCoreTests/Credential/ConfigFileCredentialProviderTests.swift +++ b/Tests/SotoCoreTests/Credential/ConfigFileCredentialProviderTests.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import AsyncHTTPClient +import struct Foundation.UUID import NIO @testable import SotoCore import SotoTestUtils @@ -20,171 +21,73 @@ import SotoXML import XCTest class ConfigFileCredentialProviderTests: XCTestCase { - func testConfigFileCredentials() { - let profile = "profile1" - let accessKey = "FAKE-ACCESS-KEY123" - let secretKey = "Asecretreglkjrd" - let sessionToken = "xyz" - let credential = """ - [\(profile)] - aws_access_key_id=\(accessKey) - aws_secret_access_key=\(secretKey) - aws_session_token=\(sessionToken) - """ - - var byteBuffer = ByteBufferAllocator().buffer(capacity: credential.utf8.count) - byteBuffer.writeString(credential) - var cred: StaticCredential? - XCTAssertNoThrow(cred = try AWSConfigFileCredentialProvider.sharedCredentials(from: byteBuffer, for: profile)) - - XCTAssertEqual(cred?.accessKeyId, accessKey) - XCTAssertEqual(cred?.secretAccessKey, secretKey) - XCTAssertEqual(cred?.sessionToken, sessionToken) - } - - func testConfigFileCredentialsMissingAccessKey() { - let profile = "profile1" - let secretKey = "Asecretreglkjrd" - let credential = """ - [\(profile)] - aws_secret_access_key=\(secretKey) - """ - - var byteBuffer = ByteBufferAllocator().buffer(capacity: credential.utf8.count) - byteBuffer.writeString(credential) - XCTAssertThrowsError(_ = try AWSConfigFileCredentialProvider.sharedCredentials(from: byteBuffer, for: profile)) { - XCTAssertEqual($0 as? AWSConfigFileCredentialProvider.ConfigFileError, .missingAccessKeyId) - } - } + // MARK: - Credential Provider - func testConfigFileCredentialsMissingSecretKey() { - let profile = "profile1" - let accessKey = "FAKE-ACCESS-KEY123" - let credential = """ - [\(profile)] - aws_access_key_id=\(accessKey) - """ - - var byteBuffer = ByteBufferAllocator().buffer(capacity: credential.utf8.count) - byteBuffer.writeString(credential) - XCTAssertThrowsError(_ = try AWSConfigFileCredentialProvider.sharedCredentials(from: byteBuffer, for: profile)) { - XCTAssertEqual($0 as? AWSConfigFileCredentialProvider.ConfigFileError, .missingSecretAccessKey) - } + func makeContext() -> (CredentialProviderFactory.Context, MultiThreadedEventLoopGroup, HTTPClient) { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let eventLoop = eventLoopGroup.next() + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(eventLoop)) + return (.init(httpClient: httpClient, eventLoop: eventLoop, logger: TestEnvironment.logger), eventLoopGroup, httpClient) } - func testConfigFileCredentialsMissingSessionToken() { - let profile = "profile1" - let accessKey = "FAKE-ACCESS-KEY123" - let secretKey = "Asecretreglkjrd" - let credential = """ - [\(profile)] - aws_access_key_id=\(accessKey) - aws_secret_access_key=\(secretKey) - """ + func testCredentialProviderStatic() { + let credentials = ConfigFileLoader.SharedCredentials.staticCredential(credential: StaticCredential(accessKeyId: "foo", secretAccessKey: "bar")) + let (context, eventLoopGroup, httpClient) = self.makeContext() - var byteBuffer = ByteBufferAllocator().buffer(capacity: credential.utf8.count) - byteBuffer.writeString(credential) - var cred: StaticCredential? - XCTAssertNoThrow(cred = try AWSConfigFileCredentialProvider.sharedCredentials(from: byteBuffer, for: profile)) - - XCTAssertEqual(cred?.accessKeyId, accessKey) - XCTAssertEqual(cred?.secretAccessKey, secretKey) - XCTAssertNil(cred?.sessionToken) - } - - func testConfigFileCredentialsMissingProfile() { - let profile = "profile1" - let accessKey = "FAKE-ACCESS-KEY123" - let secretKey = "Asecretreglkjrd" - let credential = """ - [\(profile)] - aws_access_key_id=\(accessKey) - aws_secret_access_key=\(secretKey) - """ + let provider = try? ConfigFileCredentialProvider.credentialProvider( + from: credentials, + context: context, + endpoint: nil + ) + XCTAssertEqual((provider as? StaticCredential)?.accessKeyId, "foo") + XCTAssertEqual((provider as? StaticCredential)?.secretAccessKey, "bar") - var byteBuffer = ByteBufferAllocator().buffer(capacity: credential.utf8.count) - byteBuffer.writeString(credential) - XCTAssertThrowsError(_ = try AWSConfigFileCredentialProvider.sharedCredentials(from: byteBuffer, for: "profile2")) { - XCTAssertEqual($0 as? AWSConfigFileCredentialProvider.ConfigFileError, .missingProfile("profile2")) - } + XCTAssertNoThrow(try provider?.shutdown(on: context.eventLoop).wait()) + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) } - func testConfigFileCredentialsParseFailure() { - let credential = """ - [default] - aws_access_key_id - """ + func testCredentialProviderSTSAssumeRole() { + let credentials = ConfigFileLoader.SharedCredentials.assumeRole( + roleArn: "arn", + sessionName: "baz", + region: nil, + sourceCredential: StaticCredential(accessKeyId: "foo", secretAccessKey: "bar") + ) + let (context, eventLoopGroup, httpClient) = self.makeContext() - var byteBuffer = ByteBufferAllocator().buffer(capacity: credential.utf8.count) - byteBuffer.writeString(credential) - XCTAssertThrowsError(_ = try AWSConfigFileCredentialProvider.sharedCredentials(from: byteBuffer, for: "default")) { - XCTAssertEqual($0 as? AWSConfigFileCredentialProvider.ConfigFileError, .invalidCredentialFileSyntax) + let provider = try? ConfigFileCredentialProvider.credentialProvider( + from: credentials, + context: context, + endpoint: nil + ) + XCTAssertTrue(provider is STSAssumeRoleCredentialProvider) + XCTAssertEqual((provider as? STSAssumeRoleCredentialProvider)?.request.roleArn, "arn") + + XCTAssertNoThrow(try provider?.shutdown(on: context.eventLoop).wait()) + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) + } + + func testCredentialProviderCredentialSource() { + let credentials = ConfigFileLoader.SharedCredentials.credentialSource(roleArn: "arn", source: .ec2Instance) + let (context, eventLoopGroup, httpClient) = self.makeContext() + + do { + _ = try ConfigFileCredentialProvider.credentialProvider( + from: credentials, + context: context, + endpoint: nil + ) + } catch { + XCTAssertEqual(error as? CredentialProviderError, .notSupported) } - } - func testExpandTildeInFilePath() { - let expandableFilePath = "~/.aws/credentials" - let expandedNewPath = AWSConfigFileCredentialProvider.expandTildeInFilePath(expandableFilePath) - - #if os(Linux) - XCTAssert(!expandedNewPath.hasPrefix("~")) - #else - - #if os(macOS) - // on macOS, we want to be sure the expansion produces the posix $HOME and - // not the sanboxed home $HOME/Library/Containers//Data - let macOSHomePrefix = "/Users/" - XCTAssert(expandedNewPath.starts(with: macOSHomePrefix)) - XCTAssert(!expandedNewPath.contains("/Library/Containers/")) - #endif - - // this doesn't work on linux because of SR-12843 - let expandedNSString = NSString(string: expandableFilePath).expandingTildeInPath - XCTAssertEqual(expandedNewPath, expandedNSString) - #endif - - let unexpandableFilePath = "/.aws/credentials" - let unexpandedNewPath = AWSConfigFileCredentialProvider.expandTildeInFilePath(unexpandableFilePath) - let unexpandedNSString = NSString(string: unexpandableFilePath).expandingTildeInPath - - XCTAssertEqual(unexpandedNewPath, unexpandedNSString) - XCTAssertEqual(unexpandedNewPath, unexpandableFilePath) + XCTAssertNoThrow(try httpClient.syncShutdown()) + XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) } - func testConfigFileCredentialINIParser() throws { - // setup - let credentials = """ - [default] - aws_access_key_id = AWSACCESSKEYID - aws_secret_access_key = AWSSECRETACCESSKEY - """ - let filename = "credentials" - let filenameURL = URL(fileURLWithPath: filename) - XCTAssertNoThrow(try Data(credentials.utf8).write(to: filenameURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: filenameURL)) } - - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) - defer { XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) } - let eventLoop = eventLoopGroup.next() -// let path = filenameURL.absoluteString - let threadPool = NIOThreadPool(numberOfThreads: 1) - threadPool.start() - defer { XCTAssertNoThrow(try threadPool.syncShutdownGracefully()) } - let fileIO = NonBlockingFileIO(threadPool: threadPool) - - let future = AWSConfigFileCredentialProvider.getSharedCredentialsFromDisk( - credentialsFilePath: filenameURL.path, - profile: "default", - on: eventLoop, - using: fileIO - ) - - var credential: CredentialProvider? - XCTAssertNoThrow(credential = try future.wait()) - let staticCredential = try XCTUnwrap(credential as? StaticCredential) - XCTAssertEqual(staticCredential.accessKeyId, "AWSACCESSKEYID") - XCTAssertEqual(staticCredential.secretAccessKey, "AWSSECRETACCESSKEY") - } + // MARK: - Config File Credentials Provider func testConfigFileSuccess() { let credentials = """ @@ -265,85 +168,78 @@ class ConfigFileCredentialProviderTests: XCTestCase { XCTAssertNoThrow(try client.syncShutdown()) } - func testInternalSTSAssumeRoleProvider() throws { - let credentials = STSCredentials( + // MARK: - Role ARN Credential + + func testRoleARNSourceProfile() throws { + let profile = "user1" + + // Prepare mock STSAssumeRole credentials + let stsCredentials = STSCredentials( accessKeyId: "STSACCESSKEYID", - expiration: Date(timeIntervalSince1970: 87_387_346), + expiration: Date.distantFuture, secretAccessKey: "STSSECRETACCESSKEY", sessionToken: "STSSESSIONTOKEN" ) + + // Prepare credentials file + let credentialsFile = """ + [default] + aws_access_key_id = DEFAULTACCESSKEY + aws_secret_access_key=DEFAULTSECRETACCESSKEY + aws_session_token =TOKENFOO + + [\(profile)] + role_arn = arn:aws:iam::000000000000:role/test-sts-assume-role + source_profile = default + color = ff0000 + """ + let credentialsFilePath = "credentials-" + UUID().uuidString + try credentialsFile.write(toFile: credentialsFilePath, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(atPath: credentialsFilePath) } + + // Prepare config file + let configFile = """ + region=us-west-2 + role_session_name =testRoleARNSourceProfile + """ + let configFilePath = "config-" + UUID().uuidString + try configFile.write(toFile: configFilePath, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(atPath: configFilePath) } + + // Prepare test server and AWS client let testServer = AWSTestServer(serviceProtocol: .xml) defer { XCTAssertNoThrow(try testServer.stop()) } - let client = AWSClient( - credentialProvider: .internalSTSAssumeRole( - request: .init(roleArn: "arn:aws:iam::000000000000:role/test-sts-assume-role", roleSessionName: "testInternalSTSAssumeRoleProvider"), - credentialProvider: .empty, - region: .useast1, + let httpClient = HTTPClient(eventLoopGroupProvider: .createNew) + defer { XCTAssertNoThrow(try httpClient.syncShutdown()) } + + // Here we use `.custom` provider factory, since we need to inject the testServer endpoint + let client = createAWSClient(credentialProvider: .custom { (context) -> CredentialProvider in + ConfigFileCredentialProvider( + credentialsFilePath: credentialsFilePath, + configFilePath: configFilePath, + profile: profile, + context: context, endpoint: testServer.address - ), - httpClientProvider: .createNew, - logger: TestEnvironment.logger - ) + ) + }, httpClientProvider: .shared(httpClient)) defer { XCTAssertNoThrow(try client.syncShutdown()) } - XCTAssertNoThrow(try testServer.processRaw { _ in - let output = STSAssumeRoleResponse(credentials: credentials) + // Retrieve credentials + let futureCredentials = client.credentialProvider.getCredential( + on: client.eventLoopGroup.next(), + logger: TestEnvironment.logger + ) + try testServer.processRaw { _ in + let output = STSAssumeRoleResponse(credentials: stsCredentials) let xml = try XMLEncoder().encode(output) let byteBuffer = ByteBufferAllocator().buffer(string: xml.xmlString) let response = AWSTestServer.Response(httpStatus: .ok, headers: [:], body: byteBuffer) return .result(response) - }) - var result: Credential? - XCTAssertNoThrow(result = try client.credentialProvider.getCredential(on: client.eventLoopGroup.next(), logger: AWSClient.loggingDisabled).wait()) - let stsCredentials = result as? STSCredentials - XCTAssertEqual(stsCredentials?.accessKeyId, credentials.accessKeyId) - XCTAssertEqual(stsCredentials?.expiration, credentials.expiration) - XCTAssertEqual(stsCredentials?.secretAccessKey, credentials.secretAccessKey) - XCTAssertEqual(stsCredentials?.sessionToken, credentials.sessionToken) - } -} - -// Extend STSAssumeRoleRequest so it can be used with the AWSTestServer -extension STSAssumeRoleRequest: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let roleArn = try container.decode(String.self, forKey: .roleArn) - let roleSessionName = try container.decode(String.self, forKey: .roleSessionName) - self.init(roleArn: roleArn, roleSessionName: roleSessionName) - } - - private enum CodingKeys: String, CodingKey { - case roleArn = "RoleArn" - case roleSessionName = "RoleSessionName" - } -} - -// Extend STSAssumeRoleResponse so it can be used with the AWSTestServer -extension STSAssumeRoleResponse: Encodable { - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(credentials, forKey: .credentials) - } - - private enum CodingKeys: String, CodingKey { - case credentials = "Credentials" - } -} - -// Extend STSCredentials so it can be used with the AWSTestServer -extension STSCredentials: Encodable { - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(accessKeyId, forKey: .accessKeyId) - try container.encode(expiration, forKey: .expiration) - try container.encode(secretAccessKey, forKey: .secretAccessKey) - try container.encode(sessionToken, forKey: .sessionToken) - } + } + let credentials = try futureCredentials.wait() - private enum CodingKeys: String, CodingKey { - case accessKeyId = "AccessKeyId" - case expiration = "Expiration" - case secretAccessKey = "SecretAccessKey" - case sessionToken = "SessionToken" + // Verify credentials match those returned from STS Assume Role operation + XCTAssertEqual(credentials.accessKeyId, stsCredentials.accessKeyId) + XCTAssertEqual(credentials.secretAccessKey, stsCredentials.secretAccessKey) } } diff --git a/Tests/SotoCoreTests/Credential/ConfigFileLoaderTests.swift b/Tests/SotoCoreTests/Credential/ConfigFileLoaderTests.swift new file mode 100644 index 000000000..d0fc60757 --- /dev/null +++ b/Tests/SotoCoreTests/Credential/ConfigFileLoaderTests.swift @@ -0,0 +1,731 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Soto for AWS open source project +// +// Copyright (c) 2017-2020 the Soto project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Soto project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AsyncHTTPClient +import struct Foundation.UUID +import NIO +@testable import SotoCore +import SotoTestUtils +import SotoXML +import XCTest + +class ConfigFileLoadersTests: XCTestCase { + // MARK: - File Loading + + func makeContext() throws -> (CredentialProviderFactory.Context, MultiThreadedEventLoopGroup, HTTPClient) { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let eventLoop = eventLoopGroup.next() + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(eventLoop)) + return (.init(httpClient: httpClient, eventLoop: eventLoop, logger: TestEnvironment.logger), eventLoopGroup, httpClient) + } + + func save(content: String, prefix: String) throws -> String { + let filepath = "\(prefix)-\(UUID().uuidString)" + try content.write(toFile: filepath, atomically: true, encoding: .utf8) + return filepath + } + + func testLoadFileJustCredentials() throws { + let accessKey = "AKIAIOSFODNN7EXAMPLE" + let secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + let profile = ConfigFile.defaultProfile + let credentialsFile = """ + [\(profile)] + aws_access_key_id=\(accessKey) + aws_secret_access_key= \(secretKey) + """ + + let credentialsPath = try save(content: credentialsFile, prefix: "credentials") + let (context, eventLoopGroup, httpClient) = try makeContext() + + defer { + try? FileManager.default.removeItem(atPath: credentialsPath) + try? httpClient.syncShutdown() + try? eventLoopGroup.syncShutdownGracefully() + } + + let sharedCredentials = try ConfigFileLoader.loadSharedCredentials( + credentialsFilePath: credentialsPath, + configFilePath: "/dev/null", + profile: profile, + context: context + ).wait() + + switch sharedCredentials { + case .staticCredential(let credentials): + XCTAssertEqual(credentials.accessKeyId, accessKey) + XCTAssertEqual(credentials.secretAccessKey, secretKey) + default: + XCTFail("Expected static credentials") + } + } + + func testLoadFileCredentialsAndConfig() throws { + let accessKey = "AKIAIOSFODNN7EXAMPLE" + let secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + let profile = "marketingadmin" + let sourceProfile = ConfigFile.defaultProfile + let sessionName = "foo@example.com" + let roleArn = "arn:aws:iam::123456789012:role/marketingadminrole" + let credentialsFile = """ + [\(sourceProfile)] + aws_access_key_id = \(accessKey) + aws_secret_access_key=\(secretKey) + [\(profile)] + role_arn = \(roleArn) + source_profile = \(sourceProfile) + """ + let configFile = """ + [profile \(profile)] + role_session_name = \(sessionName) + region = us-west-1 + """ + + let credentialsPath = try save(content: credentialsFile, prefix: "credentials") + let configPath = try save(content: configFile, prefix: "config") + let (context, eventLoopGroup, httpClient) = try makeContext() + + defer { + try? FileManager.default.removeItem(atPath: credentialsPath) + try? FileManager.default.removeItem(atPath: configPath) + try? httpClient.syncShutdown() + try? eventLoopGroup.syncShutdownGracefully() + } + + let sharedCredentials = try ConfigFileLoader.loadSharedCredentials( + credentialsFilePath: credentialsPath, + configFilePath: configPath, + profile: profile, + context: context + ).wait() + + switch sharedCredentials { + case .assumeRole(let aRoleArn, let aSessionName, let region, let sourceCredential): + XCTAssertEqual(sourceCredential.accessKeyId, accessKey) + XCTAssertEqual(sourceCredential.secretAccessKey, secretKey) + XCTAssertEqual(aRoleArn, roleArn) + XCTAssertEqual(aSessionName, sessionName) + XCTAssertEqual(region, .uswest1) + default: + XCTFail("Expected STS Assume Role") + } + } + + func testLoadFileConfigNotFound() throws { + let profile = "marketingadmin" + let roleArn = "arn:aws:iam::123456789012:role/marketingadminrole" + let credentialsFile = """ + [\(profile)] + role_arn = \(roleArn) + credential_source = Ec2InstanceMetadata + """ + + let credentialsPath = try save(content: credentialsFile, prefix: "credentials") + let (context, eventLoopGroup, httpClient) = try makeContext() + + defer { + try? FileManager.default.removeItem(atPath: credentialsPath) + try? httpClient.syncShutdown() + try? eventLoopGroup.syncShutdownGracefully() + } + + let sharedCredentials = try ConfigFileLoader.loadSharedCredentials( + credentialsFilePath: credentialsPath, + configFilePath: "non-existing-file-path", + profile: profile, + context: context + ).wait() + + switch sharedCredentials { + case .credentialSource(let aRoleArn, let source): + XCTAssertEqual(aRoleArn, roleArn) + XCTAssertEqual(source, .ec2Instance) + default: + XCTFail("Expected credential source") + } + } + + func testLoadFileMissingAccessKey() throws { + let secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + let profile = ConfigFile.defaultProfile + let credentialsFile = """ + [\(profile)] + aws_secret_access_key= \(secretKey) + """ + + let credentialsPath = try save(content: credentialsFile, prefix: "credentials") + let (context, eventLoopGroup, httpClient) = try makeContext() + + defer { + try? FileManager.default.removeItem(atPath: credentialsPath) + try? httpClient.syncShutdown() + try? eventLoopGroup.syncShutdownGracefully() + } + + do { + _ = try ConfigFileLoader.loadSharedCredentials( + credentialsFilePath: credentialsPath, + configFilePath: "/dev/null", + profile: profile, + context: context + ).wait() + } catch ConfigFileLoader.ConfigFileError.missingAccessKeyId { + // Pass + } catch { + XCTFail("Expected ConfigFileLoader.ConfigFileError.missingAccessKeyId, got \(error.localizedDescription)") + } + } + + func testLoadFileMissingSecretKey() throws { + let accessKey = "AKIAIOSFODNN7EXAMPLE" + let profile = ConfigFile.defaultProfile + let credentialsFile = """ + [\(profile)] + aws_access_key_id = \(accessKey) + """ + + let credentialsPath = try save(content: credentialsFile, prefix: "credentials") + let (context, eventLoopGroup, httpClient) = try makeContext() + + defer { + try? FileManager.default.removeItem(atPath: credentialsPath) + try? httpClient.syncShutdown() + try? eventLoopGroup.syncShutdownGracefully() + } + + do { + _ = try ConfigFileLoader.loadSharedCredentials( + credentialsFilePath: credentialsPath, + configFilePath: "/dev/null", + profile: profile, + context: context + ).wait() + } catch ConfigFileLoader.ConfigFileError.missingSecretAccessKey { + // Pass + } catch { + XCTFail("Expected ConfigFileLoader.ConfigFileError.missingSecretAccessKey, got \(error.localizedDescription)") + } + } + + func testLoadFileMissingSourceAccessKey() throws { + let secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + let profile = "marketingadmin" + let sourceProfile = ConfigFile.defaultProfile + let roleArn = "arn:aws:iam::123456789012:role/marketingadminrole" + let credentialsFile = """ + [\(sourceProfile)] + aws_secret_access_key=\(secretKey) + [\(profile)] + role_arn = \(roleArn) + source_profile = \(sourceProfile) + """ + + let credentialsPath = try save(content: credentialsFile, prefix: "credentials") + let (context, eventLoopGroup, httpClient) = try makeContext() + + defer { + try? FileManager.default.removeItem(atPath: credentialsPath) + try? httpClient.syncShutdown() + try? eventLoopGroup.syncShutdownGracefully() + } + + do { + _ = try ConfigFileLoader.loadSharedCredentials( + credentialsFilePath: credentialsPath, + configFilePath: "/dev/null", + profile: profile, + context: context + ).wait() + } catch ConfigFileLoader.ConfigFileError.missingAccessKeyId { + // Pass + } catch { + XCTFail("Expected ConfigFileLoader.ConfigFileError.missingAccessKeyId, got \(error.localizedDescription)") + } + } + + func testLoadFileMissingSourceSecretKey() throws { + let accessKey = "AKIAIOSFODNN7EXAMPLE" + let profile = "marketingadmin" + let sourceProfile = ConfigFile.defaultProfile + let roleArn = "arn:aws:iam::123456789012:role/marketingadminrole" + let credentialsFile = """ + [\(sourceProfile)] + aws_access_key_id = \(accessKey) + [\(profile)] + role_arn = \(roleArn) + source_profile = \(sourceProfile) + """ + + let credentialsPath = try save(content: credentialsFile, prefix: "credentials") + let (context, eventLoopGroup, httpClient) = try makeContext() + + defer { + try? FileManager.default.removeItem(atPath: credentialsPath) + try? httpClient.syncShutdown() + try? eventLoopGroup.syncShutdownGracefully() + } + + do { + _ = try ConfigFileLoader.loadSharedCredentials( + credentialsFilePath: credentialsPath, + configFilePath: "/dev/null", + profile: profile, + context: context + ).wait() + } catch ConfigFileLoader.ConfigFileError.missingSecretAccessKey { + // Pass + } catch { + XCTFail("Expected ConfigFileLoader.ConfigFileError.missingSecretAccessKey, got \(error.localizedDescription)") + } + } + + func testLoadFileRoleArnOnly() throws { + let profile = "marketingadmin" + let roleArn = "arn:aws:iam::123456789012:role/marketingadminrole" + let credentialsFile = """ + [\(profile)] + role_arn = \(roleArn) + """ + + let credentialsPath = try save(content: credentialsFile, prefix: "credentials") + let (context, eventLoopGroup, httpClient) = try makeContext() + + defer { + try? FileManager.default.removeItem(atPath: credentialsPath) + try? httpClient.syncShutdown() + try? eventLoopGroup.syncShutdownGracefully() + } + + do { + _ = try ConfigFileLoader.loadSharedCredentials( + credentialsFilePath: credentialsPath, + configFilePath: "/dev/null", + profile: profile, + context: context + ).wait() + } catch ConfigFileLoader.ConfigFileError.invalidCredentialFile { + // Pass + } catch { + XCTFail("Expected ConfigFileLoader.ConfigFileError.invalidCredentialFile, got \(error.localizedDescription)") + } + } + + // MARK: - Config File parsing + + func testConfigFileDefault() throws { + let profile = ConfigFile.defaultProfile + let sourceProfile = "user1" + let roleArn = "arn:aws:iam::123456789012:role/marketingadminrole" + let content = """ + [\(profile)] + role_arn = \(roleArn) + source_profile = \(sourceProfile) + region=us-west-2 + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + let config = try ConfigFileLoader.parseProfileConfig(from: byteBuffer, for: ConfigFile.defaultProfile) + XCTAssertEqual(config?.roleArn, roleArn) + XCTAssertEqual(config?.sourceProfile, sourceProfile) + XCTAssertEqual(config?.region, .uswest2) + } + + func testConfigFileNamedProfile() throws { + let content = """ + [default] + region=us-west-2 + output=json + + [profile marketingadmin] + role_arn = arn:aws:iam::123456789012:role/marketingadminrole + source_profile = user1 + role_session_name = foo@example.com + region = us-west-1 + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + let config = try ConfigFileLoader.parseProfileConfig(from: byteBuffer, for: "marketingadmin") + XCTAssertEqual(config?.roleArn, "arn:aws:iam::123456789012:role/marketingadminrole") + XCTAssertEqual(config?.sourceProfile, "user1") + XCTAssertEqual(config?.roleSessionName, "foo@example.com") + XCTAssertEqual(config?.region, .uswest1) + } + + func testConfigFileCredentialSourceEc2() throws { + let content = """ + [profile marketingadmin] + role_arn = arn:aws:iam::123456789012:role/marketingadminrole + credential_source = Ec2InstanceMetadata + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + let config = try ConfigFileLoader.parseProfileConfig(from: byteBuffer, for: "marketingadmin") + XCTAssertEqual(config?.credentialSource, .ec2Instance) + } + + func testConfigFileCredentialSourceEcs() throws { + let content = """ + [profile marketingadmin] + role_arn = arn:aws:iam::123456789012:role/marketingadminrole + credential_source = EcsContainer + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + let config = try ConfigFileLoader.parseProfileConfig(from: byteBuffer, for: "marketingadmin") + XCTAssertEqual(config?.credentialSource, .ecsContainer) + } + + func testConfigFileCredentialSourceEnvironment() throws { + let content = """ + [profile marketingadmin] + role_arn = arn:aws:iam::123456789012:role/marketingadminrole + credential_source = Environment + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + let config = try ConfigFileLoader.parseProfileConfig(from: byteBuffer, for: "marketingadmin") + XCTAssertEqual(config?.credentialSource, .environment) + } + + func testConfigMissingProfile() { + let content = """ + [profile foo] + bar = foo + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + do { + _ = try ConfigFileLoader.parseProfileConfig(from: byteBuffer, for: "bar") + } catch ConfigFileLoader.ConfigFileError.missingProfile(let profile) { + XCTAssertEqual(profile, "profile bar") + } catch { + XCTFail("Expected missingProfile error, got \(error.localizedDescription)") + } + } + + func testParseInvalidConfig() { + let content = """ + [profile + = foo + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + do { + _ = try ConfigFileLoader.parseProfileConfig(from: byteBuffer, for: ConfigFile.defaultProfile) + } catch ConfigFileLoader.ConfigFileError.invalidCredentialFile { + // pass + } catch { + XCTFail("Expected invalidCredentialFileSyntax error, got \(error.localizedDescription)") + } + } + + // MARK: - Credentials File parsing + + func testCredentials() throws { + let profile = "profile1" + let accessKey = "FAKE-ACCESS-KEY123" + let secretKey = "Asecretreglkjrd" + let sessionToken = "xyz" + let credential = """ + [\(profile)] + aws_access_key_id=\(accessKey) + aws_secret_access_key = \(secretKey) + aws_session_token =\(sessionToken) + """ + + var byteBuffer = ByteBufferAllocator().buffer(capacity: credential.utf8.count) + byteBuffer.writeString(credential) + + let cred = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: profile, sourceProfile: nil) + XCTAssertEqual(cred.accessKey, accessKey) + XCTAssertEqual(cred.secretAccessKey, secretKey) + XCTAssertEqual(cred.sessionToken, sessionToken) + } + + func testCredentialsMissingSessionToken() throws { + let profile = "profile1" + let accessKey = "FAKE-ACCESS-KEY123" + let secretKey = "Asecretreglkjrd" + let credential = """ + [\(profile)] + aws_access_key_id=\(accessKey) + aws_secret_access_key=\(secretKey) + """ + + var byteBuffer = ByteBufferAllocator().buffer(capacity: credential.utf8.count) + byteBuffer.writeString(credential) + + let cred = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: profile, sourceProfile: nil) + XCTAssertEqual(cred.accessKey, accessKey) + XCTAssertEqual(cred.secretAccessKey, secretKey) + XCTAssertNil(cred.sessionToken) + } + + func testCredentialsNamedProfile() throws { + let profile = "profile1" + let defaultAccessKey = "FAKE-ACCESS-KEY123" + let defaultSecretKey = "Asecretreglkjrd" + let profileAccessKey = "profile-FAKE-ACCESS-KEY123" + let profileSecretKey = "profile-Asecretreglkjrd" + let content = """ + [default] + aws_access_key_id=\(defaultAccessKey) + aws_secret_access_key=\(defaultSecretKey) + + [\(profile)] + aws_access_key_id=\(profileAccessKey) + aws_secret_access_key=\(profileSecretKey) + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + let config = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: profile, sourceProfile: nil) + XCTAssertEqual(config.accessKey, profileAccessKey) + XCTAssertEqual(config.secretAccessKey, profileSecretKey) + } + + func testCredentialsWithSourceProfile() throws { + let content = """ + [default] + aws_access_key_id=AKIAIOSFODNN7EXAMPLE + aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + + [user1] + role_arn = arn:aws:iam::123456789012:role/marketingadminrole + source_profile = default + role_session_name = foo@example.com + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + let config = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: "user1", sourceProfile: nil) + XCTAssertEqual(config.accessKey, "AKIAIOSFODNN7EXAMPLE") + XCTAssertEqual(config.secretAccessKey, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + XCTAssertEqual(config.roleArn, "arn:aws:iam::123456789012:role/marketingadminrole") + XCTAssertEqual(config.sourceProfile, ConfigFile.defaultProfile) + } + + func testCredentialsMissingProfile() { + let content = """ + [default] + aws_access_key_id=AKIAIOSFODNN7EXAMPLE + aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + do { + _ = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: "user1", sourceProfile: nil) + } catch ConfigFileLoader.ConfigFileError.missingProfile(let profile) { + XCTAssertEqual(profile, "user1") + } catch { + XCTFail("Expected missingProfile error, got \(error.localizedDescription)") + } + } + + func testCredentialsMissingSourceProfile() { + let content = """ + [foo] + aws_access_key_id=AKIAIOSFODNN7EXAMPLE + aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + do { + _ = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: "foo", sourceProfile: ConfigFile.defaultProfile) + } catch ConfigFileLoader.ConfigFileError.missingProfile(let profile) { + XCTAssertEqual(profile, ConfigFile.defaultProfile) + } catch { + XCTFail("Expected missingProfile error, got \(error.localizedDescription)") + } + } + + func testCredentialsMissingAccessKey() { + let content = """ + [foo] + aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + do { + _ = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: "foo", sourceProfile: nil) + } catch ConfigFileLoader.ConfigFileError.missingAccessKeyId { + // pass + } catch { + XCTFail("Expected missingProfile error, got \(error.localizedDescription)") + } + } + + func testCredentialsMissingSecretAccessKey() { + let content = """ + [foo] + aws_access_key_id=AKIAIOSFODNN7EXAMPLE + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + do { + _ = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: "foo", sourceProfile: nil) + } catch ConfigFileLoader.ConfigFileError.missingSecretAccessKey { + // pass + } catch { + XCTFail("Expected missingProfile error, got \(error.localizedDescription)") + } + } + + func testCredentialsMissingAccessKeyFromSourceProfile() { + let content = """ + [foo] + aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + [bar] + role_arn = arn:aws:iam::123456789012:role/marketingadminrole + source_profile = foo + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + do { + _ = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: "bar", sourceProfile: "foo") + } catch ConfigFileLoader.ConfigFileError.missingAccessKeyId { + // pass + } catch { + XCTFail("Expected missingProfile error, got \(error.localizedDescription)") + } + } + + func testCredentialsMissingSecretAccessKeyFromSourceProfile() { + let content = """ + [foo] + aws_access_key_id=AKIAIOSFODNN7EXAMPLE + [bar] + role_arn = arn:aws:iam::123456789012:role/marketingadminrole + source_profile = foo + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + do { + _ = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: "bar", sourceProfile: "foo") + } catch ConfigFileLoader.ConfigFileError.missingSecretAccessKey { + // pass + } catch { + XCTFail("Expected missingProfile error, got \(error.localizedDescription)") + } + } + + func testCredentialSourceEc2() throws { + let content = """ + [default] + aws_access_key_id=AKIAIOSFODNN7EXAMPLE + aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + [marketingadmin] + role_arn = arn:aws:iam::123456789012:role/marketingadminrole + credential_source = Ec2InstanceMetadata + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + let config = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: "marketingadmin", sourceProfile: ConfigFile.defaultProfile) + XCTAssertEqual(config.credentialSource, .ec2Instance) + } + + func testCredentialSourceEcs() throws { + let content = """ + [default] + aws_access_key_id=AKIAIOSFODNN7EXAMPLE + aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + [marketingadmin] + role_arn = arn:aws:iam::123456789012:role/marketingadminrole + credential_source = EcsContainer + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + let config = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: "marketingadmin", sourceProfile: ConfigFile.defaultProfile) + XCTAssertEqual(config.credentialSource, .ecsContainer) + } + + func testCredentialSourceEnvironment() throws { + let content = """ + [default] + aws_access_key_id=AKIAIOSFODNN7EXAMPLE + aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + [marketingadmin] + role_arn = arn:aws:iam::123456789012:role/marketingadminrole + credential_source = Environment + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + let config = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: "marketingadmin", sourceProfile: ConfigFile.defaultProfile) + XCTAssertEqual(config.credentialSource, .environment) + } + + func testParseInvalidCredentials() { + let content = """ + [profile + = foo + """ + var byteBuffer = ByteBufferAllocator().buffer(capacity: content.utf8.count) + byteBuffer.writeString(content) + + do { + _ = try ConfigFileLoader.parseCredentials(from: byteBuffer, for: ConfigFile.defaultProfile, sourceProfile: nil) + } catch ConfigFileLoader.ConfigFileError.invalidCredentialFile { + // pass + } catch { + XCTFail("Expected invalidCredentialFileSyntax error, got \(error.localizedDescription)") + } + } + + // MARK: - Config file path expansion + + func testExpandTildeInFilePath() { + let expandableFilePath = "~/.aws/credentials" + let expandedNewPath = ConfigFileLoader.expandTildeInFilePath(expandableFilePath) + + #if os(Linux) + XCTAssert(!expandedNewPath.hasPrefix("~")) + #else + + #if os(macOS) + // on macOS, we want to be sure the expansion produces the posix $HOME and + // not the sanboxed home $HOME/Library/Containers//Data + let macOSHomePrefix = "/Users/" + XCTAssert(expandedNewPath.starts(with: macOSHomePrefix)) + XCTAssert(!expandedNewPath.contains("/Library/Containers/")) + #endif + + // this doesn't work on linux because of SR-12843 + let expandedNSString = NSString(string: expandableFilePath).expandingTildeInPath + XCTAssertEqual(expandedNewPath, expandedNSString) + #endif + + let unexpandableFilePath = "/.aws/credentials" + let unexpandedNewPath = ConfigFileLoader.expandTildeInFilePath(unexpandableFilePath) + let unexpandedNSString = NSString(string: unexpandableFilePath).expandingTildeInPath + + XCTAssertEqual(unexpandedNewPath, unexpandedNSString) + XCTAssertEqual(unexpandedNewPath, unexpandableFilePath) + } +} diff --git a/Tests/SotoCoreTests/Credential/RuntimeSelectorCredentialProviderTests.swift b/Tests/SotoCoreTests/Credential/RuntimeSelectorCredentialProviderTests.swift index a5b163454..3931c82b0 100644 --- a/Tests/SotoCoreTests/Credential/RuntimeSelectorCredentialProviderTests.swift +++ b/Tests/SotoCoreTests/Credential/RuntimeSelectorCredentialProviderTests.swift @@ -198,7 +198,7 @@ class RuntimeSelectorCredentialProviderTests: XCTestCase { XCTAssertEqual(credential.secretAccessKey, "AWSSECRETACCESSKEY") XCTAssertEqual(credential.sessionToken, nil) let internalProvider = try XCTUnwrap((client.credentialProvider as? RuntimeSelectorCredentialProvider)?.internalProvider) - XCTAssert(internalProvider is DeferredCredentialProvider) + XCTAssert(internalProvider is RotatingCredentialProvider) } XCTAssertNoThrow(try futureResult.wait()) } diff --git a/Tests/SotoCoreTests/Credential/STSAssumeRoleTests.swift b/Tests/SotoCoreTests/Credential/STSAssumeRoleTests.swift new file mode 100644 index 000000000..1bfc08a31 --- /dev/null +++ b/Tests/SotoCoreTests/Credential/STSAssumeRoleTests.swift @@ -0,0 +1,104 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Soto for AWS open source project +// +// Copyright (c) 2017-2020 the Soto project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Soto project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AsyncHTTPClient +import NIO +@testable import SotoCore +import SotoTestUtils +import SotoXML +import XCTest + +class STSAssumeRoleTests: XCTestCase { + func testInternalSTSAssumeRoleProvider() throws { + let credentials = STSCredentials( + accessKeyId: "STSACCESSKEYID", + expiration: Date(timeIntervalSince1970: 87_387_346), + secretAccessKey: "STSSECRETACCESSKEY", + sessionToken: "STSSESSIONTOKEN" + ) + let testServer = AWSTestServer(serviceProtocol: .xml) + defer { XCTAssertNoThrow(try testServer.stop()) } + let client = AWSClient( + credentialProvider: .internalSTSAssumeRole( + request: .init(roleArn: "arn:aws:iam::000000000000:role/test-sts-assume-role", roleSessionName: "testInternalSTSAssumeRoleProvider"), + credentialProvider: .empty, + region: .useast1, + endpoint: testServer.address + ), + httpClientProvider: .createNew, + logger: TestEnvironment.logger + ) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + XCTAssertNoThrow(try testServer.processRaw { _ in + let output = STSAssumeRoleResponse(credentials: credentials) + let xml = try XMLEncoder().encode(output) + let byteBuffer = ByteBufferAllocator().buffer(string: xml.xmlString) + let response = AWSTestServer.Response(httpStatus: .ok, headers: [:], body: byteBuffer) + return .result(response) + }) + var result: Credential? + XCTAssertNoThrow(result = try client.credentialProvider.getCredential(on: client.eventLoopGroup.next(), logger: AWSClient.loggingDisabled).wait()) + let stsCredentials = result as? STSCredentials + XCTAssertEqual(stsCredentials?.accessKeyId, credentials.accessKeyId) + XCTAssertEqual(stsCredentials?.expiration, credentials.expiration) + XCTAssertEqual(stsCredentials?.secretAccessKey, credentials.secretAccessKey) + XCTAssertEqual(stsCredentials?.sessionToken, credentials.sessionToken) + } +} + +// Extend STSAssumeRoleRequest so it can be used with the AWSTestServer +extension STSAssumeRoleRequest: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let roleArn = try container.decode(String.self, forKey: .roleArn) + let roleSessionName = try container.decode(String.self, forKey: .roleSessionName) + self.init(roleArn: roleArn, roleSessionName: roleSessionName) + } + + private enum CodingKeys: String, CodingKey { + case roleArn = "RoleArn" + case roleSessionName = "RoleSessionName" + } +} + +// Extend STSAssumeRoleResponse so it can be used with the AWSTestServer +extension STSAssumeRoleResponse: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(credentials, forKey: .credentials) + } + + private enum CodingKeys: String, CodingKey { + case credentials = "Credentials" + } +} + +// Extend STSCredentials so it can be used with the AWSTestServer +extension STSCredentials: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(accessKeyId, forKey: .accessKeyId) + try container.encode(expiration, forKey: .expiration) + try container.encode(secretAccessKey, forKey: .secretAccessKey) + try container.encode(sessionToken, forKey: .sessionToken) + } + + private enum CodingKeys: String, CodingKey { + case accessKeyId = "AccessKeyId" + case expiration = "Expiration" + case secretAccessKey = "SecretAccessKey" + case sessionToken = "SessionToken" + } +}