diff --git a/Examples/api-key/.gitignore b/Examples/api-key/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/Examples/api-key/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Examples/api-key/Package.swift b/Examples/api-key/Package.swift new file mode 100644 index 00000000..9a9b9dc8 --- /dev/null +++ b/Examples/api-key/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "APIKey", + platforms: [.macOS(.v15), .iOS(.v18), .tvOS(.v18)], + products: [ + .executable(name: "APIKey", targets: ["APIKey"]) + ], + dependencies: [ + // for production use, uncomment the following line + // .package(url: "https://github.com/build-on-aws/swift-bedrock-library.git", branch: "main"), + + // for local development, use the following line + .package(name: "swift-bedrock-library", path: "../.."), + + .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0"), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .executableTarget( + name: "APIKey", + dependencies: [ + .product(name: "BedrockService", package: "swift-bedrock-library"), + .product(name: "Logging", package: "swift-log"), + ] + ) + ] +) diff --git a/Examples/api-key/Sources/APIKey.swift b/Examples/api-key/Sources/APIKey.swift new file mode 100644 index 00000000..42aa1402 --- /dev/null +++ b/Examples/api-key/Sources/APIKey.swift @@ -0,0 +1,19 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Bedrock Library open source project +// +// Copyright (c) 2025 Amazon.com, Inc. or its affiliates +// and the Swift Bedrock Library project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Bedrock Library project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// You can create short-lived or long-term API keys in the AWS Management Console. +// see documentation : https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-generate.html +// link to the console : https://console.aws.amazon.com/bedrock/home#/api-keys?tab=short-term +let myApiKey = "bedrock-api-key-YmVkcm9jay ... (redacted for brevity) .... b249MQ==" diff --git a/Examples/api-key/Sources/Converse.swift b/Examples/api-key/Sources/Converse.swift new file mode 100644 index 00000000..31fc3d20 --- /dev/null +++ b/Examples/api-key/Sources/Converse.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Bedrock Library open source project +// +// Copyright (c) 2025 Amazon.com, Inc. or its affiliates +// and the Swift Bedrock Library project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Bedrock Library project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import BedrockService +import Logging + +@main +struct Main { + static func main() async throws { + do { + try await Main.converse() + } catch { + print("Error:\n\(error)") + } + } + static func converse() async throws { + var logger = Logger(label: "APIKey") + logger.logLevel = .debug + + // generate an API Key in the AWS Management Console + // see https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-generate.html + let bedrock = try await BedrockService( + region: .useast1, + logger: logger, + authentication: .apiKey(key: myApiKey) // define your API Key in APIKey.swift + ) + + // select a model that supports the converse modality + // models must be enabled in your AWS account + let model: BedrockModel = .nova_lite + + guard model.hasConverseModality() else { + throw MyError.incorrectModality("\(model.name) does not support converse") + } + + // create a request + let builder = try ConverseRequestBuilder(with: model) + .withPrompt("What is an API key?") + + // send the request + let reply = try await bedrock.converse(with: builder) + + print("Assistant: \(reply)") + } + + enum MyError: Error { + case incorrectModality(String) + } +} diff --git a/README.md b/README.md index bbe25503..6b0c0f1d 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,96 @@ Use the `listModels()` function to test your set-up. This function will return a let models = try await bedrock.listModels() ``` +## Authentication + +The Swift Bedrock Library supports multiple authentication methods to work with Amazon Bedrock. By default, it uses the standard AWS credential provider chain, but you can specify different authentication types when initializing the `BedrockService`. + +### Default Authentication + +Uses the standard AWS credential provider chain, which checks for credentials in the following order: +1. Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) +2. AWS credentials file (`~/.aws/credentials`) +3. AWS config file (`~/.aws/config`) +4. IAM roles for Amazon EC2 instances +5. IAM roles for tasks (Amazon ECS) +6. IAM roles for Lambda functions + +```swift +let bedrock = try await BedrockService( + region: .uswest2 + // authentication defaults to .default +) +``` + +### Profile-based Authentication + +Use a specific profile from your AWS credentials file. This is useful when you have multiple AWS accounts or roles configured locally. + +```swift +let bedrock = try await BedrockService( + region: .uswest2, + authentication: .profile(profileName: "my-profile") +) +``` + +### SSO Authentication + +Use AWS Single Sign-On (SSO) authentication. You must run `aws sso login --profile ` before using this authentication method. + +```swift +let bedrock = try await BedrockService( + region: .uswest2, + authentication: .sso(profileName: "my-sso-profile") +) +``` + +### Web Identity Token Authentication + +Use a JWT token from an external identity provider (like Sign In with Apple or Google) to assume an IAM role. This is particularly useful for iOS, tvOS, and macOS applications where traditional AWS CLI-based authentication isn't available. + +```swift +let bedrock = try await BedrockService( + region: .uswest2, + authentication: .webIdentity( + token: jwtToken, + roleARN: "arn:aws:iam::123456789012:role/MyAppRole", + region: .uswest2, + notification: { + // Optional: Called on main thread when credentials are retrieved + print("AWS credentials updated") + } + ) +) +``` + +### API Key Authentication + +Use an API key for authentication. API keys are generated in the AWS console and provide a simpler authentication method for specific use cases. + +```swift +let bedrock = try await BedrockService( + region: .uswest2, + authentication: .apiKey(key: "your-api-key-here") +) +``` + +### Static Credentials Authentication + +Use static AWS credentials directly. **This method is strongly discouraged for production use** and should only be used for testing and debugging purposes. + +```swift +let bedrock = try await BedrockService( + region: .uswest2, + authentication: .static( + accessKey: "AKIAIOSFODNN7EXAMPLE", + secretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + sessionToken: "optional-session-token" + ) +) +``` + +**Security Note**: Never hardcode credentials in your source code or commit them to version control. Use environment variables, secure credential storage, or other secure methods to manage credentials in production applications. + ## Chatting using the Converse or ConverseStream API ### Text prompt diff --git a/Sources/BedrockAuthentication.swift b/Sources/BedrockAuthentication.swift index 186d39e2..f5f1d49a 100644 --- a/Sources/BedrockAuthentication.swift +++ b/Sources/BedrockAuthentication.swift @@ -24,12 +24,14 @@ import SmithyIdentity /// - `webIdentity`: Use a web identity token (JWT) to assume an IAM role. This is useful for applications running on iOS, tvOS or macOS where you cannot use the AWS CLI. Typically, the application authenticates the user with an external Identity provider (such as Sign In with Apple or Login With Google) and receives a JWT token. The application then uses this token to assume an IAM role and receive temporary AWS credentials. Some additional configuration is required on your AWS account to allow this. See https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc.html for more information. If you use Sign In With Apple, read https://docs.aws.amazon.com/sdk-for-swift/latest/developer-guide/apple-integration.html for more information. /// Because `webidentity` is often used by application presenting a user interface. This method of authentication allows you to pass an optional closure that will be called when the credentials are retrieved. This is useful for updating the UI or notifying the user. The closure is called on the main (UI) thread. /// - `static`: Use static AWS credentials. We strongly recommend to not use this option in production. This might be useful in some rare cases when testing and debugging. +/// - `apiKey`: Use an API key to authenticate. This is useful for applications that do not require full AWS credentials and only need to access specific APIs. The API key is passed as a string. API Keys are generated in the AWS console. public enum BedrockAuthentication: Sendable, CustomStringConvertible { case `default` case profile(profileName: String = "default") case sso(profileName: String = "default") case webIdentity(token: String, roleARN: String, region: Region, notification: @Sendable () -> Void = {}) case `static`(accessKey: String, secretKey: String, sessionToken: String) + case apiKey(key: String) public var description: String { switch self { @@ -43,6 +45,8 @@ public enum BedrockAuthentication: Sendable, CustomStringConvertible { return "webIdentity: \(redactingSecret(secret: token)), roleARN: \(roleARN), region: \(region)" case .static(let accessKey, let secretKey, _): return "static: \(accessKey), secretKey: \(redactingSecret(secret: secretKey))" + case .apiKey(let key): + return "apiKey: \(redactingSecret(secret: key))" } } private func redactingSecret(secret: String) -> String { @@ -59,7 +63,8 @@ public enum BedrockAuthentication: Sendable, CustomStringConvertible { ) async throws -> (any SmithyIdentity.AWSCredentialIdentityResolver)? { switch self { - case .default: + case .default, + .apiKey(_): return nil case .profile(let profileName): return try? ProfileAWSCredentialIdentityResolver(profileName: profileName) @@ -74,7 +79,7 @@ public enum BedrockAuthentication: Sendable, CustomStringConvertible { notify: notification ) case .static(let accessKey, let secretKey, let sessionToken): - logger.warning("Using static AWS credentials. This is not recommended for production.") + logger.info("Using static AWS credentials. This is not recommended for production.") let creds = AWSCredentialIdentity(accessKey: accessKey, secret: secretKey, sessionToken: sessionToken) return StaticAWSCredentialIdentityResolver(creds) } diff --git a/Sources/BedrockService.swift b/Sources/BedrockService.swift index 6186e600..b63b4d05 100644 --- a/Sources/BedrockService.swift +++ b/Sources/BedrockService.swift @@ -20,6 +20,13 @@ import AwsCommonRuntimeKit import Foundation import Logging +// for setenv and unsetenv functions +#if os(Linux) +import Glibc +#else +import Darwin.C +#endif + public struct BedrockService: Sendable { package let region: Region package let logger: Logging.Logger @@ -106,21 +113,18 @@ public struct BedrockService: Sendable { /// - authentication: The authentication type to use /// - Returns: Configured BedrockClientProtocol instance /// - Throws: Error if client creation fails - static private func createBedrockClient( + internal static func createBedrockClient( region: Region, authentication: BedrockAuthentication, logger: Logging.Logger ) async throws - -> BedrockClientProtocol + -> BedrockClient { - let config = try await BedrockClient.BedrockClientConfiguration( - region: region.rawValue - ) - if let awsCredentialIdentityResolver = try? await authentication.getAWSCredentialIdentityResolver( + let config: BedrockClient.BedrockClientConfiguration = try await prepareConfig( + region: region, + authentication: authentication, logger: logger - ) { - config.awsCredentialIdentityResolver = awsCredentialIdentityResolver - } + ) return BedrockClient(config: config) } @@ -130,24 +134,56 @@ public struct BedrockService: Sendable { /// - authentication: The authentication type to use /// - Returns: Configured BedrockRuntimeClientProtocol instance /// - Throws: Error if client creation fails - static private func createBedrockRuntimeClient( + internal static func createBedrockRuntimeClient( region: Region, authentication: BedrockAuthentication, logger: Logging.Logger ) async throws - -> BedrockRuntimeClientProtocol + -> BedrockRuntimeClient { - let config = - try await BedrockRuntimeClient.BedrockRuntimeClientConfiguration( - region: region.rawValue - ) + let config: BedrockRuntimeClient.BedrockRuntimeClientConfiguration = try await prepareConfig( + region: region, + authentication: authentication, + logger: logger + ) + return BedrockRuntimeClient(config: config) + } + + /// Generic function to create client configuration and avoid duplication code. + internal static func prepareConfig( + region: Region, + authentication: BedrockAuthentication, + logger: Logging.Logger + ) async throws -> C { + var config: C = try await .init() + + config.region = region.rawValue + + // support profile, SSO, web identity and static authentication if let awsCredentialIdentityResolver = try? await authentication.getAWSCredentialIdentityResolver( logger: logger ) { config.awsCredentialIdentityResolver = awsCredentialIdentityResolver } - return BedrockRuntimeClient(config: config) + + // support API keys + if case .apiKey(let key) = authentication { + config.httpClientConfiguration.defaultHeaders.add( + name: "Authorization", + value: "Bearer \(key)" + ) + logger.trace("Using API Key for authentication") + } else { + logger.trace("Using AWS credentials for authentication") + } + + //We uncheck AWS_BEARER_TOKEN_BEDROCK to avoid conflict with future AWS SDK version + //see https://docs.aws.amazon.com/bedrock/latest/userguide/getting-started-api-keys.html + //FIXME: there is a risk of side effect here - what other ways we have to ignore this variable ? + unsetenv("AWS_BEARER_TOKEN_BEDROCK") + + return config } func handleCommonError(_ error: Error, context: String) throws -> Never { diff --git a/Sources/Protocols/BedrockConfigProtocol.swift b/Sources/Protocols/BedrockConfigProtocol.swift new file mode 100644 index 00000000..89d9dc9b --- /dev/null +++ b/Sources/Protocols/BedrockConfigProtocol.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Bedrock Library open source project +// +// Copyright (c) 2025 Amazon.com, Inc. or its affiliates +// and the Swift Bedrock Library project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Bedrock Library project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AWSBedrock +import AWSBedrockRuntime +import ClientRuntime +import SmithyIdentity + +protocol BedrockConfigProtocol { + init() async throws + var awsCredentialIdentityResolver: any SmithyIdentity.AWSCredentialIdentityResolver { get set } + var httpClientConfiguration: ClientRuntime.HttpClientConfiguration { get set } + var region: String? { get set } +} +extension BedrockClient.BedrockClientConfiguration: @retroactive @unchecked Sendable, BedrockConfigProtocol {} +extension BedrockRuntimeClient.BedrockRuntimeClientConfiguration: @retroactive @unchecked Sendable, + BedrockConfigProtocol +{} diff --git a/Tests/AuthenticationTests.swift b/Tests/AuthenticationTests.swift index a284349b..ea4bd89d 100644 --- a/Tests/AuthenticationTests.swift +++ b/Tests/AuthenticationTests.swift @@ -13,11 +13,26 @@ // //===----------------------------------------------------------------------===// +import AWSBedrock import AwsCommonRuntimeKit +import Logging import Testing @testable import BedrockService +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +// for setenv and unsetenv functions +#if os(Linux) +import Glibc +#else +import Darwin.C +#endif + // MARK: authentication extension BedrockServiceTests { @@ -35,6 +50,7 @@ extension BedrockServiceTests { region: .useast1, notification: {} ), + BedrockAuthentication.apiKey(key: "MY_SECRET_API_KEY"), ] ) func authNoLeaks(auth: BedrockAuthentication) { @@ -62,4 +78,82 @@ extension BedrockServiceTests { // let _ = try await bedrock.listModels() // } // } + + @Test("Authentication: API Key authentication adds HTTP Header to the request") + func apiKeyAuthentication() async throws { + // given + let testApiKey = "test-api-key-12345" + let auth = BedrockAuthentication.apiKey(key: testApiKey) + + // when + // create bedrock configuration with API Key authentication + let config: BedrockClient.BedrockClientConfiguration = try await BedrockService.prepareConfig( + region: .useast1, + authentication: auth, + logger: Logger(label: "test.logger"), + ) + + // then + #expect(config.region == Region.useast1.rawValue) // default region + #expect( + config.httpClientConfiguration.defaultHeaders.value(for: "Authorization") == "Bearer test-api-key-12345" + ) + + } + + @Test("Authentication: API Key returns nil credential resolver") + func apiKeyCredentialResolver() async throws { + // given + let testApiKey = "test-api-key-12345" + let auth = BedrockAuthentication.apiKey(key: testApiKey) + let logger = Logger(label: "test.logger") + + // when + let resolver = try await auth.getAWSCredentialIdentityResolver(logger: logger) + + // then + #expect(resolver == nil, "API Key authentication should return nil credential resolver") + } + + @Test("Authentication: API Key description doesn't leak full key") + func apiKeyDescription() { + // given + let testApiKey = "test-api-key-12345-very-long-key" + let auth = BedrockAuthentication.apiKey(key: testApiKey) + + // when + let description = auth.description + + // then + #expect(description.contains("apiKey:")) + #expect(description.contains("tes...")) // should show first 3 characters + #expect(description.contains("*** shuuut, it's a secret ***")) + #expect(!description.contains("12345")) // should not contain the full key + #expect(!description.contains("very-long-key")) // should not contain the full key + } + + @Test("Authentication: AWS_BEARER_TOKEN_BEDROCK is unset after prepareConfig") + func awsBearerTokenBedrockUnset() async throws { + // given + let testApiKey = "test-api-key-12345" + let auth = BedrockAuthentication.apiKey(key: testApiKey) + let logger = Logger(label: "test.logger") + + // Set the environment variable before calling prepareConfig + setenv("AWS_BEARER_TOKEN_BEDROCK", "some-bearer-token", 1) + + // Verify it's set before the test + #expect(ProcessInfo.processInfo.environment["AWS_BEARER_TOKEN_BEDROCK"] == "some-bearer-token") + + // when + let _: BedrockClient.BedrockClientConfiguration = try await BedrockService.prepareConfig( + region: .useast1, + authentication: auth, + logger: logger + ) + + // then + // Verify that AWS_BEARER_TOKEN_BEDROCK is no longer set + #expect(ProcessInfo.processInfo.environment["AWS_BEARER_TOKEN_BEDROCK"] == nil) + } }