From 74f7b757e4a3d068cfdece89be30e64c1a4f717e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 19 May 2026 18:19:05 +0700 Subject: [PATCH 1/2] fix(plugin-dynamodb): connect with SSO sso_session profiles after aws sso login --- CHANGELOG.md | 1 + .../DynamoDBConnection.swift | 255 ++++++++++++------ 2 files changed, 176 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3cc5ee7..557c32ecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - DuckDB ENUMs in non-`main` schemas resolve correctly - DuckDB `DATE` and `TIMESTAMP` BC years use a leading minus - `.db`, `.db3`, `.s3db`, `.sl3`, and `.sqlitedb` files now open in TablePro from Finder (#1327) +- DynamoDB SSO connections work with modern `sso-session` profiles immediately after `aws sso login`, without needing to run another AWS CLI command first (#1333) ## [0.43.0] - 2026-05-18 diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift index 690994b28..610e5b25f 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift @@ -240,6 +240,7 @@ private struct SsoProfileSettings { let accountId: String let roleName: String let startUrl: String + let region: String let ssoSession: String? } @@ -301,7 +302,7 @@ internal final class DynamoDBConnection: @unchecked Sendable { } func connect() async throws { - let credentials = try resolveCredentials() + let credentials = try await resolveCredentials() let sessionConfig = URLSessionConfiguration.default sessionConfig.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout sessionConfig.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout @@ -604,7 +605,7 @@ internal final class DynamoDBConnection: @unchecked Sendable { // MARK: - Credential Resolution - private func resolveCredentials() throws -> AWSCredentials { + private func resolveCredentials() async throws -> AWSCredentials { let authMethod = config.additionalFields["awsAuthMethod"] ?? "credentials" switch authMethod { @@ -613,7 +614,7 @@ internal final class DynamoDBConnection: @unchecked Sendable { case "profile": return try resolveProfileCredentials() case "sso": - return try resolveSsoCredentials() + return try await resolveSsoCredentials() default: return try resolveAccessKeyCredentials() } @@ -686,126 +687,220 @@ internal final class DynamoDBConnection: @unchecked Sendable { ) } - private func resolveSsoCredentials() throws -> AWSCredentials { + private func resolveSsoCredentials() async throws -> AWSCredentials { let profileName = config.additionalFields["awsProfileName"] ?? "default" - let ssoSettings = try parseSsoProfileSettings(profileName: profileName) - let cliCachePath = NSString("~/.aws/cli/cache").expandingTildeInPath - - // Compute the expected cache filename from the profile's SSO settings. - // The AWS CLI caches credentials using SHA1 of a minified JSON with sorted keys. - let cacheKey: String - if let sessionName = ssoSettings.ssoSession { - // Session-based SSO: {"accountId":"...","roleName":"...","sessionName":"..."} - cacheKey = "{\"accountId\":\"\(ssoSettings.accountId)\",\"roleName\":\"\(ssoSettings.roleName)\",\"sessionName\":\"\(sessionName)\"}" - } else { - // Legacy SSO: {"accountId":"...","roleName":"...","startUrl":"..."} - cacheKey = "{\"accountId\":\"\(ssoSettings.accountId)\",\"roleName\":\"\(ssoSettings.roleName)\",\"startUrl\":\"\(ssoSettings.startUrl)\"}" - } + let settings = try parseSsoProfileSettings(profileName: profileName) + let accessToken = try readSsoAccessToken(settings: settings, profileName: profileName) + return try await fetchSsoRoleCredentials( + accessToken: accessToken, + settings: settings, + profileName: profileName + ) + } + private func readSsoAccessToken(settings: SsoProfileSettings, profileName: String) throws -> String { + let cacheDir = NSString("~/.aws/sso/cache").expandingTildeInPath + let cacheKey = settings.ssoSession ?? settings.startUrl let cacheFileName = sha1Hex(Data(cacheKey.utf8)) + ".json" - let cacheFilePath = (cliCachePath as NSString).appendingPathComponent(cacheFileName) + let cacheFilePath = (cacheDir as NSString).appendingPathComponent(cacheFileName) guard let data = FileManager.default.contents(atPath: cacheFilePath) else { throw DynamoDBError.authFailed( - "SSO cache file not found for profile '\(profileName)' at \(cacheFilePath). Run 'aws sso login --profile \(profileName)' first." + "SSO token cache not found for profile '\(profileName)'. Run 'aws sso login --profile \(profileName)' first." ) } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw DynamoDBError.authFailed("Invalid SSO cache file for profile '\(profileName)'") + struct TokenCache: Decodable { + let accessToken: String + let expiresAt: String + } + + let token: TokenCache + do { + token = try JSONDecoder().decode(TokenCache.self, from: data) + } catch { + throw DynamoDBError.authFailed( + "SSO token cache for profile '\(profileName)' is malformed. Run 'aws sso login --profile \(profileName)' to refresh." + ) } - guard let accessKeyId = json["AccessKeyId"] as? String, - let secretAccessKey = json["SecretAccessKey"] as? String, - let sessionToken = json["SessionToken"] as? String - else { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expiresAt = formatter.date(from: token.expiresAt) ?? ISO8601DateFormatter().date(from: token.expiresAt) + if let expiresAt, expiresAt <= Date() { throw DynamoDBError.authFailed( - "SSO cache file for profile '\(profileName)' is missing credential fields. Run 'aws sso login --profile \(profileName)' first." + "SSO session for profile '\(profileName)' has expired. Run 'aws sso login --profile \(profileName)' to refresh." ) } - if let expiresAtStr = json["Expiration"] as? String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let expiresAt = formatter.date(from: expiresAtStr) ?? ISO8601DateFormatter().date(from: expiresAtStr), - expiresAt <= Date() - { - throw DynamoDBError.authFailed( - "SSO credentials for profile '\(profileName)' have expired. Run 'aws sso login --profile \(profileName)' to refresh." - ) + return token.accessToken + } + + private func fetchSsoRoleCredentials( + accessToken: String, + settings: SsoProfileSettings, + profileName: String + ) async throws -> AWSCredentials { + var components = URLComponents(string: "https://portal.sso.\(settings.region).amazonaws.com/federation/credentials") + components?.queryItems = [ + URLQueryItem(name: "account_id", value: settings.accountId), + URLQueryItem(name: "role_name", value: settings.roleName) + ] + guard let url = components?.url else { + throw DynamoDBError.authFailed("Failed to build SSO portal URL for profile '\(profileName)'") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(accessToken, forHTTPHeaderField: "x-amz-sso_bearer_token") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw DynamoDBError.authFailed( + "Failed to reach SSO portal for profile '\(profileName)': \(error.localizedDescription)" + ) + } + + guard let http = response as? HTTPURLResponse else { + throw DynamoDBError.authFailed("Unexpected response from SSO portal for profile '\(profileName)'") + } + + switch http.statusCode { + case 200: + break + case 401: + throw DynamoDBError.authFailed( + "SSO session for profile '\(profileName)' has expired. Run 'aws sso login --profile \(profileName)' to refresh." + ) + case 403: + throw DynamoDBError.authFailed( + "Role '\(settings.roleName)' in account '\(settings.accountId)' is not accessible via SSO. Check role permissions in AWS IAM Identity Center." + ) + default: + throw DynamoDBError.authFailed( + "SSO portal returned HTTP \(http.statusCode) for profile '\(profileName)'" + ) + } + + struct RoleCredentialsEnvelope: Decodable { + struct RoleCredentials: Decodable { + let accessKeyId: String + let secretAccessKey: String + let sessionToken: String + let expiration: Int64 } + let roleCredentials: RoleCredentials + } + + let envelope: RoleCredentialsEnvelope + do { + envelope = try JSONDecoder().decode(RoleCredentialsEnvelope.self, from: data) + } catch { + throw DynamoDBError.authFailed( + "Failed to decode SSO portal response for profile '\(profileName)'" + ) + } + + let expiry = Date(timeIntervalSince1970: TimeInterval(envelope.roleCredentials.expiration) / 1_000) + if expiry <= Date() { + throw DynamoDBError.authFailed( + "SSO role credentials for profile '\(profileName)' were already expired on issue. Run 'aws sso login --profile \(profileName)' to refresh." + ) } return AWSCredentials( - accessKeyId: accessKeyId, - secretAccessKey: secretAccessKey, - sessionToken: sessionToken + accessKeyId: envelope.roleCredentials.accessKeyId, + secretAccessKey: envelope.roleCredentials.secretAccessKey, + sessionToken: envelope.roleCredentials.sessionToken ) } - /// Parse SSO settings from ~/.aws/config for the given profile. private func parseSsoProfileSettings(profileName: String) throws -> SsoProfileSettings { let configPath = NSString("~/.aws/config").expandingTildeInPath guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { throw DynamoDBError.authFailed("Cannot read ~/.aws/config") } - // In ~/.aws/config, the default profile is [default], others are [profile ] - let targetSection = profileName == "default" ? "default" : "profile \(profileName)" + let sections = parseIniSections(content) + let profileSection = profileName == "default" ? "default" : "profile \(profileName)" + + guard let profile = sections[profileSection] else { + throw DynamoDBError.authFailed("Profile '\(profileName)' not found in ~/.aws/config") + } + + guard let accountId = profile["sso_account_id"], let roleName = profile["sso_role_name"] else { + throw DynamoDBError.authFailed( + "Profile '\(profileName)' in ~/.aws/config is missing sso_account_id or sso_role_name" + ) + } + + let ssoSession = profile["sso_session"] + let resolvedStartUrl: String + let resolvedRegion: String - var currentSection = "" - var accountId: String? - var roleName: String? - var startUrl: String? - var ssoSession: String? + if let sessionName = ssoSession { + guard let session = sections["sso-session \(sessionName)"] else { + throw DynamoDBError.authFailed( + "SSO session '\(sessionName)' referenced by profile '\(profileName)' not found in ~/.aws/config" + ) + } + guard let startUrl = session["sso_start_url"], let region = session["sso_region"] else { + throw DynamoDBError.authFailed( + "SSO session '\(sessionName)' in ~/.aws/config is missing sso_start_url or sso_region" + ) + } + resolvedStartUrl = startUrl + resolvedRegion = region + } else { + guard let startUrl = profile["sso_start_url"], let region = profile["sso_region"] else { + throw DynamoDBError.authFailed( + "Profile '\(profileName)' in ~/.aws/config is missing sso_start_url or sso_region (required for legacy SSO)" + ) + } + resolvedStartUrl = startUrl + resolvedRegion = region + } + + return SsoProfileSettings( + accountId: accountId, + roleName: roleName, + startUrl: resolvedStartUrl, + region: resolvedRegion, + ssoSession: ssoSession + ) + } + + private func parseIniSections(_ content: String) -> [String: [String: String]] { + var sections: [String: [String: String]] = [:] + var current = "" for line in content.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") || trimmed.hasPrefix(";") { continue } + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { - currentSection = String(trimmed.dropFirst().dropLast()) + current = String(trimmed.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces) + if sections[current] == nil { + sections[current] = [:] + } continue } - guard currentSection == targetSection else { continue } + + guard !current.isEmpty else { continue } let parts = trimmed.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } guard parts.count == 2 else { continue } - switch parts[0] { - case "sso_account_id": - accountId = parts[1] - case "sso_role_name": - roleName = parts[1] - case "sso_start_url": - startUrl = parts[1] - case "sso_session": - ssoSession = parts[1] - default: - break - } + sections[current, default: [:]][parts[0]] = parts[1] } - guard let resolvedAccountId = accountId, let resolvedRoleName = roleName else { - throw DynamoDBError.authFailed( - "Profile '\(profileName)' in ~/.aws/config is missing sso_account_id or sso_role_name" - ) - } - - // startUrl is required for legacy SSO (when sso_session is not set) - let resolvedStartUrl = startUrl ?? "" - if ssoSession == nil && resolvedStartUrl.isEmpty { - throw DynamoDBError.authFailed( - "Profile '\(profileName)' in ~/.aws/config is missing sso_start_url (required for legacy SSO)" - ) - } - - return SsoProfileSettings( - accountId: resolvedAccountId, - roleName: resolvedRoleName, - startUrl: resolvedStartUrl, - ssoSession: ssoSession - ) + return sections } private func sha1Hex(_ data: Data) -> String { From 501c64e8b0c6541d342e9daf73dddee9a7049d2f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 19 May 2026 18:35:18 +0700 Subject: [PATCH 2/2] refactor(plugin-dynamodb): extract SSO helpers into testable namespace with XCTest coverage --- .../DynamoDBConnection.swift | 239 +---------- .../DynamoDBSsoCredentials.swift | 272 +++++++++++++ TablePro.xcodeproj/project.pbxproj | 4 + .../DynamoDBSsoCredentials.swift | 1 + .../Plugins/DynamoDBSsoCredentialsTests.swift | 380 ++++++++++++++++++ 5 files changed, 678 insertions(+), 218 deletions(-) create mode 100644 Plugins/DynamoDBDriverPlugin/DynamoDBSsoCredentials.swift create mode 120000 TableProTests/PluginTestSources/DynamoDBSsoCredentials.swift create mode 100644 TableProTests/Plugins/DynamoDBSsoCredentialsTests.swift diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift index 610e5b25f..76f060ad6 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift @@ -236,14 +236,6 @@ internal struct ExecuteStatementResponse: Decodable { let LastEvaluatedKey: [String: DynamoDBAttributeValue]? } -private struct SsoProfileSettings { - let accountId: String - let roleName: String - let startUrl: String - let region: String - let ssoSession: String? -} - private struct DynamoDBErrorResponse: Decodable { let __type: String? let message: String? @@ -689,226 +681,37 @@ internal final class DynamoDBConnection: @unchecked Sendable { private func resolveSsoCredentials() async throws -> AWSCredentials { let profileName = config.additionalFields["awsProfileName"] ?? "default" - let settings = try parseSsoProfileSettings(profileName: profileName) - let accessToken = try readSsoAccessToken(settings: settings, profileName: profileName) - return try await fetchSsoRoleCredentials( - accessToken: accessToken, - settings: settings, - profileName: profileName - ) - } - - private func readSsoAccessToken(settings: SsoProfileSettings, profileName: String) throws -> String { + let configPath = NSString("~/.aws/config").expandingTildeInPath let cacheDir = NSString("~/.aws/sso/cache").expandingTildeInPath - let cacheKey = settings.ssoSession ?? settings.startUrl - let cacheFileName = sha1Hex(Data(cacheKey.utf8)) + ".json" - let cacheFilePath = (cacheDir as NSString).appendingPathComponent(cacheFileName) - guard let data = FileManager.default.contents(atPath: cacheFilePath) else { - throw DynamoDBError.authFailed( - "SSO token cache not found for profile '\(profileName)'. Run 'aws sso login --profile \(profileName)' first." - ) - } - - struct TokenCache: Decodable { - let accessToken: String - let expiresAt: String + guard let configContent = try? String(contentsOfFile: configPath, encoding: .utf8) else { + throw DynamoDBError.authFailed(SsoCredentialError.configReadFailed.userMessage) } - let token: TokenCache do { - token = try JSONDecoder().decode(TokenCache.self, from: data) - } catch { - throw DynamoDBError.authFailed( - "SSO token cache for profile '\(profileName)' is malformed. Run 'aws sso login --profile \(profileName)' to refresh." + let settings = try DynamoDBSso.parseProfileSettings( + configContent: configContent, + profileName: profileName ) - } - - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let expiresAt = formatter.date(from: token.expiresAt) ?? ISO8601DateFormatter().date(from: token.expiresAt) - if let expiresAt, expiresAt <= Date() { - throw DynamoDBError.authFailed( - "SSO session for profile '\(profileName)' has expired. Run 'aws sso login --profile \(profileName)' to refresh." + let accessToken = try DynamoDBSso.readAccessToken( + cacheDirectory: cacheDir, + settings: settings, + profileName: profileName ) - } - - return token.accessToken - } - - private func fetchSsoRoleCredentials( - accessToken: String, - settings: SsoProfileSettings, - profileName: String - ) async throws -> AWSCredentials { - var components = URLComponents(string: "https://portal.sso.\(settings.region).amazonaws.com/federation/credentials") - components?.queryItems = [ - URLQueryItem(name: "account_id", value: settings.accountId), - URLQueryItem(name: "role_name", value: settings.roleName) - ] - guard let url = components?.url else { - throw DynamoDBError.authFailed("Failed to build SSO portal URL for profile '\(profileName)'") - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue(accessToken, forHTTPHeaderField: "x-amz-sso_bearer_token") - request.setValue("application/json", forHTTPHeaderField: "Accept") - - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - throw DynamoDBError.authFailed( - "Failed to reach SSO portal for profile '\(profileName)': \(error.localizedDescription)" + let credentials = try await DynamoDBSso.fetchRoleCredentials( + accessToken: accessToken, + settings: settings, + profileName: profileName, + session: URLSession.shared ) - } - - guard let http = response as? HTTPURLResponse else { - throw DynamoDBError.authFailed("Unexpected response from SSO portal for profile '\(profileName)'") - } - - switch http.statusCode { - case 200: - break - case 401: - throw DynamoDBError.authFailed( - "SSO session for profile '\(profileName)' has expired. Run 'aws sso login --profile \(profileName)' to refresh." - ) - case 403: - throw DynamoDBError.authFailed( - "Role '\(settings.roleName)' in account '\(settings.accountId)' is not accessible via SSO. Check role permissions in AWS IAM Identity Center." - ) - default: - throw DynamoDBError.authFailed( - "SSO portal returned HTTP \(http.statusCode) for profile '\(profileName)'" - ) - } - - struct RoleCredentialsEnvelope: Decodable { - struct RoleCredentials: Decodable { - let accessKeyId: String - let secretAccessKey: String - let sessionToken: String - let expiration: Int64 - } - let roleCredentials: RoleCredentials - } - - let envelope: RoleCredentialsEnvelope - do { - envelope = try JSONDecoder().decode(RoleCredentialsEnvelope.self, from: data) - } catch { - throw DynamoDBError.authFailed( - "Failed to decode SSO portal response for profile '\(profileName)'" - ) - } - - let expiry = Date(timeIntervalSince1970: TimeInterval(envelope.roleCredentials.expiration) / 1_000) - if expiry <= Date() { - throw DynamoDBError.authFailed( - "SSO role credentials for profile '\(profileName)' were already expired on issue. Run 'aws sso login --profile \(profileName)' to refresh." + return AWSCredentials( + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken ) + } catch let error as SsoCredentialError { + throw DynamoDBError.authFailed(error.userMessage) } - - return AWSCredentials( - accessKeyId: envelope.roleCredentials.accessKeyId, - secretAccessKey: envelope.roleCredentials.secretAccessKey, - sessionToken: envelope.roleCredentials.sessionToken - ) - } - - private func parseSsoProfileSettings(profileName: String) throws -> SsoProfileSettings { - let configPath = NSString("~/.aws/config").expandingTildeInPath - guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { - throw DynamoDBError.authFailed("Cannot read ~/.aws/config") - } - - let sections = parseIniSections(content) - let profileSection = profileName == "default" ? "default" : "profile \(profileName)" - - guard let profile = sections[profileSection] else { - throw DynamoDBError.authFailed("Profile '\(profileName)' not found in ~/.aws/config") - } - - guard let accountId = profile["sso_account_id"], let roleName = profile["sso_role_name"] else { - throw DynamoDBError.authFailed( - "Profile '\(profileName)' in ~/.aws/config is missing sso_account_id or sso_role_name" - ) - } - - let ssoSession = profile["sso_session"] - let resolvedStartUrl: String - let resolvedRegion: String - - if let sessionName = ssoSession { - guard let session = sections["sso-session \(sessionName)"] else { - throw DynamoDBError.authFailed( - "SSO session '\(sessionName)' referenced by profile '\(profileName)' not found in ~/.aws/config" - ) - } - guard let startUrl = session["sso_start_url"], let region = session["sso_region"] else { - throw DynamoDBError.authFailed( - "SSO session '\(sessionName)' in ~/.aws/config is missing sso_start_url or sso_region" - ) - } - resolvedStartUrl = startUrl - resolvedRegion = region - } else { - guard let startUrl = profile["sso_start_url"], let region = profile["sso_region"] else { - throw DynamoDBError.authFailed( - "Profile '\(profileName)' in ~/.aws/config is missing sso_start_url or sso_region (required for legacy SSO)" - ) - } - resolvedStartUrl = startUrl - resolvedRegion = region - } - - return SsoProfileSettings( - accountId: accountId, - roleName: roleName, - startUrl: resolvedStartUrl, - region: resolvedRegion, - ssoSession: ssoSession - ) - } - - private func parseIniSections(_ content: String) -> [String: [String: String]] { - var sections: [String: [String: String]] = [:] - var current = "" - - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty || trimmed.hasPrefix("#") || trimmed.hasPrefix(";") { continue } - - if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { - current = String(trimmed.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces) - if sections[current] == nil { - sections[current] = [:] - } - continue - } - - guard !current.isEmpty else { continue } - - let parts = trimmed.split(separator: "=", maxSplits: 1).map { - $0.trimmingCharacters(in: .whitespaces) - } - guard parts.count == 2 else { continue } - - sections[current, default: [:]][parts[0]] = parts[1] - } - - return sections - } - - private func sha1Hex(_ data: Data) -> String { - var hash = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) - data.withUnsafeBytes { ptr in - _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &hash) - } - return hash.map { String(format: "%02x", $0) }.joined() } // MARK: - Helpers diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBSsoCredentials.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBSsoCredentials.swift new file mode 100644 index 000000000..752a34492 --- /dev/null +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBSsoCredentials.swift @@ -0,0 +1,272 @@ +// +// DynamoDBSsoCredentials.swift +// DynamoDBDriverPlugin +// +// AWS SSO credential resolution: reads the OIDC access token from +// ~/.aws/sso/cache/ and exchanges it for STS credentials via the SSO portal +// GetRoleCredentials endpoint. Matches the flow used by AWS SDKs. +// + +import CommonCrypto +import Foundation + +struct SsoProfileSettings: Equatable, Sendable { + let accountId: String + let roleName: String + let startUrl: String + let region: String + let ssoSession: String? +} + +struct SsoRoleCredentials: Equatable, Sendable { + let accessKeyId: String + let secretAccessKey: String + let sessionToken: String +} + +enum SsoCredentialError: Error, Equatable { + case configReadFailed + case profileNotFound(String) + case profileMissingFields(profile: String) + case sessionNotFound(profile: String, session: String) + case sessionMissingFields(session: String) + case profileMissingUrlOrRegion(String) + case tokenCacheNotFound(profile: String) + case tokenCacheMalformed(profile: String) + case tokenExpired(profile: String) + case urlBuildFailed(profile: String) + case networkFailure(profile: String, underlying: String) + case invalidResponse(profile: String) + case sessionUnauthorized(profile: String) + case roleNotAccessible(role: String, account: String) + case portalError(profile: String, status: Int) + case responseDecodeFailed(profile: String) + case credentialsAlreadyExpired(profile: String) + + var userMessage: String { + switch self { + case .configReadFailed: + return "Cannot read ~/.aws/config" + case .profileNotFound(let profile): + return "Profile '\(profile)' not found in ~/.aws/config" + case .profileMissingFields(let profile): + return "Profile '\(profile)' in ~/.aws/config is missing sso_account_id or sso_role_name" + case .sessionNotFound(let profile, let session): + return "SSO session '\(session)' referenced by profile '\(profile)' not found in ~/.aws/config" + case .sessionMissingFields(let session): + return "SSO session '\(session)' in ~/.aws/config is missing sso_start_url or sso_region" + case .profileMissingUrlOrRegion(let profile): + return "Profile '\(profile)' in ~/.aws/config is missing sso_start_url or sso_region (required for legacy SSO)" + case .tokenCacheNotFound(let profile): + return "SSO token cache not found for profile '\(profile)'. Run 'aws sso login --profile \(profile)' first." + case .tokenCacheMalformed(let profile): + return "SSO token cache for profile '\(profile)' is malformed. Run 'aws sso login --profile \(profile)' to refresh." + case .tokenExpired(let profile), .sessionUnauthorized(let profile): + return "SSO session for profile '\(profile)' has expired. Run 'aws sso login --profile \(profile)' to refresh." + case .urlBuildFailed(let profile): + return "Failed to build SSO portal URL for profile '\(profile)'" + case .networkFailure(let profile, let underlying): + return "Failed to reach SSO portal for profile '\(profile)': \(underlying)" + case .invalidResponse(let profile): + return "Unexpected response from SSO portal for profile '\(profile)'" + case .roleNotAccessible(let role, let account): + return "Role '\(role)' in account '\(account)' is not accessible via SSO. Check role permissions in AWS IAM Identity Center." + case .portalError(let profile, let status): + return "SSO portal returned HTTP \(status) for profile '\(profile)'" + case .responseDecodeFailed(let profile): + return "Failed to decode SSO portal response for profile '\(profile)'" + case .credentialsAlreadyExpired(let profile): + return "SSO role credentials for profile '\(profile)' were already expired on arrival. Run 'aws sso login --profile \(profile)' to refresh." + } + } +} + +enum DynamoDBSso { + static func parseIniSections(_ content: String) -> [String: [String: String]] { + var sections: [String: [String: String]] = [:] + var current = "" + + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") || trimmed.hasPrefix(";") { continue } + + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + current = String(trimmed.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces) + if sections[current] == nil { + sections[current] = [:] + } + continue + } + + guard !current.isEmpty else { continue } + + let parts = trimmed.split(separator: "=", maxSplits: 1).map { + $0.trimmingCharacters(in: .whitespaces) + } + guard parts.count == 2 else { continue } + + sections[current, default: [:]][parts[0]] = parts[1] + } + + return sections + } + + static func parseProfileSettings(configContent: String, profileName: String) throws -> SsoProfileSettings { + let sections = parseIniSections(configContent) + let profileSection = profileName == "default" ? "default" : "profile \(profileName)" + + guard let profile = sections[profileSection] else { + throw SsoCredentialError.profileNotFound(profileName) + } + + guard let accountId = profile["sso_account_id"], let roleName = profile["sso_role_name"] else { + throw SsoCredentialError.profileMissingFields(profile: profileName) + } + + let ssoSession = profile["sso_session"] + let resolvedStartUrl: String + let resolvedRegion: String + + if let sessionName = ssoSession { + guard let session = sections["sso-session \(sessionName)"] else { + throw SsoCredentialError.sessionNotFound(profile: profileName, session: sessionName) + } + guard let startUrl = session["sso_start_url"], let region = session["sso_region"] else { + throw SsoCredentialError.sessionMissingFields(session: sessionName) + } + resolvedStartUrl = startUrl + resolvedRegion = region + } else { + guard let startUrl = profile["sso_start_url"], let region = profile["sso_region"] else { + throw SsoCredentialError.profileMissingUrlOrRegion(profileName) + } + resolvedStartUrl = startUrl + resolvedRegion = region + } + + return SsoProfileSettings( + accountId: accountId, + roleName: roleName, + startUrl: resolvedStartUrl, + region: resolvedRegion, + ssoSession: ssoSession + ) + } + + static func readAccessToken( + cacheDirectory: String, + settings: SsoProfileSettings, + profileName: String, + now: Date = Date() + ) throws -> String { + let cacheKey = settings.ssoSession ?? settings.startUrl + let cacheFileName = sha1Hex(Data(cacheKey.utf8)) + ".json" + let cacheFilePath = (cacheDirectory as NSString).appendingPathComponent(cacheFileName) + + guard let data = FileManager.default.contents(atPath: cacheFilePath) else { + throw SsoCredentialError.tokenCacheNotFound(profile: profileName) + } + + struct TokenCache: Decodable { + let accessToken: String + let expiresAt: String + } + + let token: TokenCache + do { + token = try JSONDecoder().decode(TokenCache.self, from: data) + } catch { + throw SsoCredentialError.tokenCacheMalformed(profile: profileName) + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expiresAt = formatter.date(from: token.expiresAt) ?? ISO8601DateFormatter().date(from: token.expiresAt) + if let expiresAt, expiresAt <= now { + throw SsoCredentialError.tokenExpired(profile: profileName) + } + + return token.accessToken + } + + static func fetchRoleCredentials( + accessToken: String, + settings: SsoProfileSettings, + profileName: String, + session: URLSession, + now: Date = Date() + ) async throws -> SsoRoleCredentials { + var components = URLComponents(string: "https://portal.sso.\(settings.region).amazonaws.com/federation/credentials") + components?.queryItems = [ + URLQueryItem(name: "account_id", value: settings.accountId), + URLQueryItem(name: "role_name", value: settings.roleName) + ] + guard let url = components?.url else { + throw SsoCredentialError.urlBuildFailed(profile: profileName) + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(accessToken, forHTTPHeaderField: "x-amz-sso_bearer_token") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw SsoCredentialError.networkFailure(profile: profileName, underlying: error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw SsoCredentialError.invalidResponse(profile: profileName) + } + + switch http.statusCode { + case 200: + break + case 401: + throw SsoCredentialError.sessionUnauthorized(profile: profileName) + case 403: + throw SsoCredentialError.roleNotAccessible(role: settings.roleName, account: settings.accountId) + default: + throw SsoCredentialError.portalError(profile: profileName, status: http.statusCode) + } + + struct RoleCredentialsEnvelope: Decodable { + struct RoleCredentials: Decodable { + let accessKeyId: String + let secretAccessKey: String + let sessionToken: String + let expiration: Int64 + } + let roleCredentials: RoleCredentials + } + + let envelope: RoleCredentialsEnvelope + do { + envelope = try JSONDecoder().decode(RoleCredentialsEnvelope.self, from: data) + } catch { + throw SsoCredentialError.responseDecodeFailed(profile: profileName) + } + + let expiry = Date(timeIntervalSince1970: TimeInterval(envelope.roleCredentials.expiration) / 1_000) + if expiry <= now { + throw SsoCredentialError.credentialsAlreadyExpired(profile: profileName) + } + + return SsoRoleCredentials( + accessKeyId: envelope.roleCredentials.accessKeyId, + secretAccessKey: envelope.roleCredentials.secretAccessKey, + sessionToken: envelope.roleCredentials.sessionToken + ) + } + + static func sha1Hex(_ data: Data) -> String { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) + data.withUnsafeBytes { ptr in + _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &hash) + } + return hash.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 8fbc52531..c4f31c1b7 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ 5ADDB00100000000000000A6 /* DynamoDBQueryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A6 /* DynamoDBQueryBuilder.swift */; }; 5ADDB00100000000000000A7 /* DynamoDBStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A7 /* DynamoDBStatementGenerator.swift */; }; 5ADDB00100000000000000A8 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5ADDB00100000000000000F0 /* DynamoDBSsoCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000F0 /* DynamoDBSsoCredentials.swift */; }; 5AE4F4902F6BC0640097AC5B /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5AEA8B422F6808CA0040461A /* EtcdStatementGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B402F6808CA0040461A /* EtcdStatementGenerator.swift */; }; 5AEA8B432F6808CA0040461A /* EtcdPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3D2F6808CA0040461A /* EtcdPlugin.swift */; }; @@ -314,6 +315,7 @@ 5ADDB00200000000000000A5 /* DynamoDBPluginDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBPluginDriver.swift; sourceTree = ""; }; 5ADDB00200000000000000A6 /* DynamoDBQueryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBQueryBuilder.swift; sourceTree = ""; }; 5ADDB00200000000000000A7 /* DynamoDBStatementGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBStatementGenerator.swift; sourceTree = ""; }; + 5ADDB00200000000000000F0 /* DynamoDBSsoCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamoDBSsoCredentials.swift; sourceTree = ""; }; 5ADDB00300000000000000A0 /* DynamoDBDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DynamoDBDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CloudflareD1DriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EtcdDriverPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1006,6 +1008,7 @@ 5ADDB00200000000000000A5 /* DynamoDBPluginDriver.swift */, 5ADDB00200000000000000A6 /* DynamoDBQueryBuilder.swift */, 5ADDB00200000000000000A7 /* DynamoDBStatementGenerator.swift */, + 5ADDB00200000000000000F0 /* DynamoDBSsoCredentials.swift */, ); path = Plugins/DynamoDBDriverPlugin; sourceTree = ""; @@ -2099,6 +2102,7 @@ 5ADDB00100000000000000A5 /* DynamoDBPluginDriver.swift in Sources */, 5ADDB00100000000000000A6 /* DynamoDBQueryBuilder.swift in Sources */, 5ADDB00100000000000000A7 /* DynamoDBStatementGenerator.swift in Sources */, + 5ADDB00100000000000000F0 /* DynamoDBSsoCredentials.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TableProTests/PluginTestSources/DynamoDBSsoCredentials.swift b/TableProTests/PluginTestSources/DynamoDBSsoCredentials.swift new file mode 120000 index 000000000..922a274ac --- /dev/null +++ b/TableProTests/PluginTestSources/DynamoDBSsoCredentials.swift @@ -0,0 +1 @@ +../../Plugins/DynamoDBDriverPlugin/DynamoDBSsoCredentials.swift \ No newline at end of file diff --git a/TableProTests/Plugins/DynamoDBSsoCredentialsTests.swift b/TableProTests/Plugins/DynamoDBSsoCredentialsTests.swift new file mode 100644 index 000000000..cff78e580 --- /dev/null +++ b/TableProTests/Plugins/DynamoDBSsoCredentialsTests.swift @@ -0,0 +1,380 @@ +// +// DynamoDBSsoCredentialsTests.swift +// TableProTests +// +// Tests for DynamoDBSso helpers (compiled via symlink from DynamoDBDriverPlugin). +// + +import Foundation +import Testing + +private let modernConfig = """ +# top-level comment + +[default] +region = ap-southeast-1 + +[sso-session my-sso] +sso_start_url = https://example.awsapps.com/start#/ +sso_region = eu-west-1 +sso_registration_scopes = sso:account:access + +[profile my-profile] +sso_session = my-sso +sso_account_id = 111111111111 +sso_role_name = AWSAdministratorAccess +region = eu-west-1 + +[profile legacy-profile] +sso_start_url = https://legacy.awsapps.com/start +sso_region = us-east-1 +sso_account_id = 222222222222 +sso_role_name = LegacyRole +""" + +@Suite("DynamoDBSso - INI parsing") +struct DynamoDBSsoIniTests { + @Test("comment lines and empty lines are skipped") + func skipsCommentsAndEmptyLines() { + let content = """ + # hello + ; semicolon comment + + [default] + region = us-east-1 + """ + let sections = DynamoDBSso.parseIniSections(content) + #expect(sections["default"]?["region"] == "us-east-1") + #expect(sections.count == 1) + } + + @Test("section + key/value parsed; URL with # in value preserved") + func preservesHashInValues() { + let sections = DynamoDBSso.parseIniSections(modernConfig) + #expect(sections["sso-session my-sso"]?["sso_start_url"] == "https://example.awsapps.com/start#/") + } + + @Test("orphan key before any section is dropped") + func dropsOrphanKey() { + let content = "rogue = value\n[default]\nregion = us-east-1\n" + let sections = DynamoDBSso.parseIniSections(content) + #expect(sections["default"]?["region"] == "us-east-1") + #expect(sections.keys.contains("rogue") == false) + } +} + +@Suite("DynamoDBSso - parseProfileSettings") +struct DynamoDBSsoProfileTests { + @Test("modern profile resolves all fields from sso-session block") + func resolvesModernProfile() throws { + let s = try DynamoDBSso.parseProfileSettings(configContent: modernConfig, profileName: "my-profile") + #expect(s.accountId == "111111111111") + #expect(s.roleName == "AWSAdministratorAccess") + #expect(s.startUrl == "https://example.awsapps.com/start#/") + #expect(s.region == "eu-west-1") + #expect(s.ssoSession == "my-sso") + } + + @Test("legacy profile resolves fields from profile block") + func resolvesLegacyProfile() throws { + let s = try DynamoDBSso.parseProfileSettings(configContent: modernConfig, profileName: "legacy-profile") + #expect(s.startUrl == "https://legacy.awsapps.com/start") + #expect(s.region == "us-east-1") + #expect(s.ssoSession == nil) + } + + @Test("missing profile throws profileNotFound") + func throwsOnMissingProfile() { + #expect(throws: SsoCredentialError.profileNotFound("ghost")) { + _ = try DynamoDBSso.parseProfileSettings(configContent: modernConfig, profileName: "ghost") + } + } + + @Test("profile missing account/role throws profileMissingFields") + func throwsOnIncompleteProfile() { + let content = "[profile partial]\nsso_account_id = 1\n" + #expect(throws: SsoCredentialError.profileMissingFields(profile: "partial")) { + _ = try DynamoDBSso.parseProfileSettings(configContent: content, profileName: "partial") + } + } + + @Test("modern profile referencing unknown session throws sessionNotFound") + func throwsOnMissingSession() { + let content = """ + [profile orphan] + sso_session = does-not-exist + sso_account_id = 1 + sso_role_name = R + """ + #expect(throws: SsoCredentialError.sessionNotFound(profile: "orphan", session: "does-not-exist")) { + _ = try DynamoDBSso.parseProfileSettings(configContent: content, profileName: "orphan") + } + } + + @Test("legacy profile missing url/region throws profileMissingUrlOrRegion") + func throwsOnLegacyMissingUrlRegion() { + let content = """ + [profile bare] + sso_account_id = 1 + sso_role_name = R + """ + #expect(throws: SsoCredentialError.profileMissingUrlOrRegion("bare")) { + _ = try DynamoDBSso.parseProfileSettings(configContent: content, profileName: "bare") + } + } +} + +@Suite("DynamoDBSso - readAccessToken") +struct DynamoDBSsoTokenTests { + private func makeCacheDirectory() throws -> String { + let dir = NSTemporaryDirectory() + "DynamoDBSsoTokenTests_\(UUID().uuidString)/" + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + return dir + } + + private func writeTokenFile(at directory: String, key: String, contents: String) throws { + let path = (directory as NSString).appendingPathComponent(DynamoDBSso.sha1Hex(Data(key.utf8)) + ".json") + try contents.write(toFile: path, atomically: true, encoding: .utf8) + } + + private let modernSettings = SsoProfileSettings( + accountId: "111111111111", + roleName: "AWSAdministratorAccess", + startUrl: "https://example.awsapps.com/start#/", + region: "eu-west-1", + ssoSession: "my-sso" + ) + + @Test("returns accessToken when cache file is fresh") + func returnsFreshToken() throws { + let dir = try makeCacheDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let future = ISO8601DateFormatter().string(from: Date().addingTimeInterval(3_600)) + try writeTokenFile( + at: dir, + key: "my-sso", + contents: #"{"accessToken":"OIDC_TOKEN","expiresAt":"\#(future)"}"# + ) + let token = try DynamoDBSso.readAccessToken( + cacheDirectory: dir, settings: modernSettings, profileName: "my-profile" + ) + #expect(token == "OIDC_TOKEN") + } + + @Test("throws tokenExpired when expiresAt is past") + func throwsOnExpired() throws { + let dir = try makeCacheDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let past = ISO8601DateFormatter().string(from: Date().addingTimeInterval(-3_600)) + try writeTokenFile( + at: dir, key: "my-sso", + contents: #"{"accessToken":"OLD","expiresAt":"\#(past)"}"# + ) + #expect(throws: SsoCredentialError.tokenExpired(profile: "my-profile")) { + _ = try DynamoDBSso.readAccessToken( + cacheDirectory: dir, settings: modernSettings, profileName: "my-profile" + ) + } + } + + @Test("throws tokenCacheNotFound when file missing") + func throwsOnMissingFile() throws { + let dir = try makeCacheDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + #expect(throws: SsoCredentialError.tokenCacheNotFound(profile: "my-profile")) { + _ = try DynamoDBSso.readAccessToken( + cacheDirectory: dir, settings: modernSettings, profileName: "my-profile" + ) + } + } + + @Test("throws tokenCacheMalformed when JSON is bad") + func throwsOnMalformed() throws { + let dir = try makeCacheDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + try writeTokenFile(at: dir, key: "my-sso", contents: "{not json") + #expect(throws: SsoCredentialError.tokenCacheMalformed(profile: "my-profile")) { + _ = try DynamoDBSso.readAccessToken( + cacheDirectory: dir, settings: modernSettings, profileName: "my-profile" + ) + } + } + + @Test("legacy settings use startUrl as cache key") + func legacyUsesStartUrl() throws { + let dir = try makeCacheDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let future = ISO8601DateFormatter().string(from: Date().addingTimeInterval(3_600)) + let legacy = SsoProfileSettings( + accountId: "2", + roleName: "R", + startUrl: "https://legacy.example/start", + region: "us-east-1", + ssoSession: nil + ) + try writeTokenFile( + at: dir, key: legacy.startUrl, + contents: #"{"accessToken":"LEGACY","expiresAt":"\#(future)"}"# + ) + let token = try DynamoDBSso.readAccessToken( + cacheDirectory: dir, settings: legacy, profileName: "legacy-profile" + ) + #expect(token == "LEGACY") + } +} + +private final class SsoStubProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var status: Int = 200 + nonisolated(unsafe) static var body = Data() + nonisolated(unsafe) static var captured: URLRequest? + nonisolated(unsafe) static var simulateNetworkError: Bool = false + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.captured = request + if Self.simulateNetworkError { + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + return + } + guard let url = request.url, + let resp = HTTPURLResponse( + url: url, statusCode: Self.status, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + ) + else { return } + client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Self.body) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} + + static func reset() { + status = 200 + body = Data() + captured = nil + simulateNetworkError = false + } + + static func makeSession() -> URLSession { + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [SsoStubProtocol.self] + return URLSession(configuration: cfg) + } +} + +@Suite("DynamoDBSso - fetchRoleCredentials") +struct DynamoDBSsoFetchTests { + private let settings = SsoProfileSettings( + accountId: "111111111111", + roleName: "AWSAdministratorAccess", + startUrl: "https://example.awsapps.com/start#/", + region: "eu-west-1", + ssoSession: "my-sso" + ) + + @Test("200 response: URL host/path/query/header match AWS spec and credentials decode") + func happyPath() async throws { + SsoStubProtocol.reset() + let exp = Int64(Date().addingTimeInterval(3_600).timeIntervalSince1970 * 1_000) + SsoStubProtocol.body = Data(#""" + {"roleCredentials":{"accessKeyId":"AK","secretAccessKey":"SK","sessionToken":"ST","expiration":\#(exp)}} + """#.utf8) + + let creds = try await DynamoDBSso.fetchRoleCredentials( + accessToken: "BEARER", settings: settings, profileName: "p", + session: SsoStubProtocol.makeSession() + ) + + let req = try #require(SsoStubProtocol.captured) + let url = try #require(req.url) + #expect(req.httpMethod == "GET") + #expect(url.host == "portal.sso.eu-west-1.amazonaws.com") + #expect(url.path == "/federation/credentials") + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + #expect(queryItems.contains(URLQueryItem(name: "account_id", value: "111111111111"))) + #expect(queryItems.contains(URLQueryItem(name: "role_name", value: "AWSAdministratorAccess"))) + #expect(req.value(forHTTPHeaderField: "x-amz-sso_bearer_token") == "BEARER") + #expect(creds.accessKeyId == "AK") + #expect(creds.secretAccessKey == "SK") + #expect(creds.sessionToken == "ST") + } + + @Test("401 maps to sessionUnauthorized") + func unauthorized() async throws { + SsoStubProtocol.reset() + SsoStubProtocol.status = 401 + await #expect(throws: SsoCredentialError.sessionUnauthorized(profile: "p")) { + _ = try await DynamoDBSso.fetchRoleCredentials( + accessToken: "T", settings: settings, profileName: "p", + session: SsoStubProtocol.makeSession() + ) + } + } + + @Test("403 maps to roleNotAccessible carrying role and account") + func forbidden() async throws { + SsoStubProtocol.reset() + SsoStubProtocol.status = 403 + await #expect( + throws: SsoCredentialError.roleNotAccessible(role: "AWSAdministratorAccess", account: "111111111111") + ) { + _ = try await DynamoDBSso.fetchRoleCredentials( + accessToken: "T", settings: settings, profileName: "p", + session: SsoStubProtocol.makeSession() + ) + } + } + + @Test("5xx maps to portalError with status code") + func serverError() async throws { + SsoStubProtocol.reset() + SsoStubProtocol.status = 503 + await #expect(throws: SsoCredentialError.portalError(profile: "p", status: 503)) { + _ = try await DynamoDBSso.fetchRoleCredentials( + accessToken: "T", settings: settings, profileName: "p", + session: SsoStubProtocol.makeSession() + ) + } + } + + @Test("network failure maps to networkFailure") + func networkFailure() async throws { + SsoStubProtocol.reset() + SsoStubProtocol.simulateNetworkError = true + await #expect(throws: SsoCredentialError.self) { + _ = try await DynamoDBSso.fetchRoleCredentials( + accessToken: "T", settings: settings, profileName: "p", + session: SsoStubProtocol.makeSession() + ) + } + } + + @Test("200 with credentials whose expiration is past throws credentialsAlreadyExpired") + func credentialsAlreadyExpired() async throws { + SsoStubProtocol.reset() + let pastMs = Int64(Date().addingTimeInterval(-60).timeIntervalSince1970 * 1_000) + SsoStubProtocol.body = Data(#""" + {"roleCredentials":{"accessKeyId":"AK","secretAccessKey":"SK","sessionToken":"ST","expiration":\#(pastMs)}} + """#.utf8) + await #expect(throws: SsoCredentialError.credentialsAlreadyExpired(profile: "p")) { + _ = try await DynamoDBSso.fetchRoleCredentials( + accessToken: "T", settings: settings, profileName: "p", + session: SsoStubProtocol.makeSession() + ) + } + } + + @Test("200 with malformed JSON throws responseDecodeFailed") + func malformedResponse() async throws { + SsoStubProtocol.reset() + SsoStubProtocol.body = Data("not json".utf8) + await #expect(throws: SsoCredentialError.responseDecodeFailed(profile: "p")) { + _ = try await DynamoDBSso.fetchRoleCredentials( + accessToken: "T", settings: settings, profileName: "p", + session: SsoStubProtocol.makeSession() + ) + } + } +}