diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 2122500d1..666a62294 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -65,6 +65,12 @@ export interface BedrockOptions extends StreamOptions { */ thinkingDisplay?: BedrockThinkingDisplay; } +const AUTHENTICATED_API_KEY_SENTINEL = ""; + +function resolveBearerToken(options: BedrockOptions): string | undefined { + const apiKey = options.apiKey === AUTHENTICATED_API_KEY_SENTINEL ? undefined : options.apiKey; + return options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK; +} type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string }; @@ -234,7 +240,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( accept: "application/vnd.amazon.eventstream", }; - const bearerToken = options.bearerToken || options.apiKey || $env.AWS_BEARER_TOKEN_BEDROCK; + const bearerToken = resolveBearerToken(options); let requestHeaders: Record; if (bearerToken) { requestHeaders = { ...baseHeaders, Authorization: `Bearer ${bearerToken}` }; diff --git a/packages/ai/test/issue-1399-repro.test.ts b/packages/ai/test/issue-1399-repro.test.ts index 80d68d55a..f03e74a90 100644 --- a/packages/ai/test/issue-1399-repro.test.ts +++ b/packages/ai/test/issue-1399-repro.test.ts @@ -38,11 +38,22 @@ const awsEnvKeys = [ "AWS_BEARER_TOKEN_BEDROCK", "AWS_BEDROCK_SKIP_AUTH", ] as const; +function snapshotAwsEnv(): () => void { + const previous = new Map(); + for (const key of awsEnvKeys) previous.set(key, process.env[key]); + return () => { + for (const key of awsEnvKeys) { + const value = previous.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + clearAwsCredentialCache(); + }; +} describe("issue #1399: Bedrock bearer token precedence", () => { it("uses AWS_BEARER_TOKEN_BEDROCK without invoking profile credential_process", async () => { - const previous = new Map(); - for (const key of awsEnvKeys) previous.set(key, process.env[key]); + const restoreAwsEnv = snapshotAwsEnv(); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-bedrock-auth-")); try { @@ -84,13 +95,76 @@ describe("issue #1399: Bedrock bearer token precedence", () => { expect(result.errorMessage).toContain("Bedrock HTTP 401"); expect(result.errorMessage).not.toContain("credential_process"); } finally { - for (const key of awsEnvKeys) { - const value = previous.get(key); - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - clearAwsCredentialCache(); + restoreAwsEnv(); await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("ignores agent sentinel apiKey when AWS_BEARER_TOKEN_BEDROCK is available", async () => { + const restoreAwsEnv = snapshotAwsEnv(); + try { + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_SESSION_TOKEN; + delete process.env.AWS_PROFILE; + delete process.env.AWS_CONFIG_FILE; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_BEDROCK_SKIP_AUTH; + process.env.AWS_REGION = "us-west-2"; + process.env.AWS_SHARED_CREDENTIALS_FILE = path.join(os.tmpdir(), "missing-aws-credentials"); + process.env.AWS_EC2_METADATA_DISABLED = "true"; + process.env.AWS_BEARER_TOKEN_BEDROCK = "bedrock-api-key"; + clearAwsCredentialCache(); + + let requestHeaders: Headers | undefined; + using _hook = hookFetch((_input, init) => { + requestHeaders = new Headers(init?.headers); + return new Response('{"message":"unauthorized"}', { status: 401 }); + }); + + const result = await streamBedrock(model, context, { apiKey: "" }).result(); + + expect(requestHeaders?.get("authorization")).toBe("Bearer bedrock-api-key"); + expect(requestHeaders?.get("authorization")).not.toBe("Bearer "); + expect(requestHeaders?.has("x-amz-date")).toBe(false); + expect(result.stopReason).toBe("error"); + } finally { + restoreAwsEnv(); + } + }); + + it("ignores agent sentinel apiKey when signing with AWS credentials", async () => { + const restoreAwsEnv = snapshotAwsEnv(); + try { + process.env.AWS_ACCESS_KEY_ID = "AKIDEXAMPLE"; + process.env.AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"; + delete process.env.AWS_SESSION_TOKEN; + delete process.env.AWS_PROFILE; + delete process.env.AWS_CONFIG_FILE; + delete process.env.AWS_SHARED_CREDENTIALS_FILE; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_BEARER_TOKEN_BEDROCK; + delete process.env.AWS_BEDROCK_SKIP_AUTH; + process.env.AWS_REGION = "us-west-2"; + process.env.AWS_EC2_METADATA_DISABLED = "true"; + clearAwsCredentialCache(); + + let requestHeaders: Headers | undefined; + using _hook = hookFetch((_input, init) => { + requestHeaders = new Headers(init?.headers); + return new Response('{"message":"unauthorized"}', { status: 401 }); + }); + + const result = await streamBedrock(model, context, { apiKey: "" }).result(); + + const authorization = requestHeaders?.get("authorization"); + expect(authorization).toStartWith("AWS4-HMAC-SHA256 "); + expect(authorization).toContain("Credential=AKIDEXAMPLE/"); + expect(authorization).not.toBe("Bearer "); + expect(requestHeaders?.has("x-amz-date")).toBe(true); + expect(result.stopReason).toBe("error"); + } finally { + restoreAwsEnv(); + } + }); });