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

Filter by extension

Filter by extension

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

Expand Down
158 changes: 28 additions & 130 deletions Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,13 +236,6 @@ internal struct ExecuteStatementResponse: Decodable {
let LastEvaluatedKey: [String: DynamoDBAttributeValue]?
}

private struct SsoProfileSettings {
let accountId: String
let roleName: String
let startUrl: String
let ssoSession: String?
}

private struct DynamoDBErrorResponse: Decodable {
let __type: String?
let message: String?
Expand Down Expand Up @@ -301,7 +294,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
Expand Down Expand Up @@ -604,7 +597,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 {
Expand All @@ -613,7 +606,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()
}
Expand Down Expand Up @@ -686,134 +679,39 @@ 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 cacheFileName = sha1Hex(Data(cacheKey.utf8)) + ".json"
let cacheFilePath = (cliCachePath 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."
)
}

guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw DynamoDBError.authFailed("Invalid SSO cache file for profile '\(profileName)'")
}

guard let accessKeyId = json["AccessKeyId"] as? String,
let secretAccessKey = json["SecretAccessKey"] as? String,
let sessionToken = json["SessionToken"] as? String
else {
throw DynamoDBError.authFailed(
"SSO cache file for profile '\(profileName)' is missing credential fields. Run 'aws sso login --profile \(profileName)' first."
)
}

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 AWSCredentials(
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: 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 <name>]
let targetSection = profileName == "default" ? "default" : "profile \(profileName)"

var currentSection = ""
var accountId: String?
var roleName: String?
var startUrl: String?
var ssoSession: String?
let cacheDir = NSString("~/.aws/sso/cache").expandingTildeInPath

for line in content.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") {
currentSection = String(trimmed.dropFirst().dropLast())
continue
}
guard currentSection == targetSection 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
}
guard let configContent = try? String(contentsOfFile: configPath, encoding: .utf8) else {
throw DynamoDBError.authFailed(SsoCredentialError.configReadFailed.userMessage)
}

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"
do {
let settings = try DynamoDBSso.parseProfileSettings(
configContent: configContent,
profileName: profileName
)
}

// 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)"
let accessToken = try DynamoDBSso.readAccessToken(
cacheDirectory: cacheDir,
settings: settings,
profileName: profileName
)
let credentials = try await DynamoDBSso.fetchRoleCredentials(
accessToken: accessToken,
settings: settings,
profileName: profileName,
session: URLSession.shared
)
return AWSCredentials(
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
sessionToken: credentials.sessionToken
)
} catch let error as SsoCredentialError {
throw DynamoDBError.authFailed(error.userMessage)
}

return SsoProfileSettings(
accountId: resolvedAccountId,
roleName: resolvedRoleName,
startUrl: resolvedStartUrl,
ssoSession: ssoSession
)
}

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
Expand Down
Loading
Loading