diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 70cae75bdd..5b71c141b7 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -34,6 +34,7 @@ BEDROCK_ACCESS_KEY_ID= BEDROCK_SECRET_ACCESS_KEY= BEDROCK_SESSION_TOKEN= BEDROCK_REGION= +BEDROCK_USE_CONTAINER_CREDENTIALS= DEEPSEEK_API_KEY= GEMINI_API_KEY= GROQ_API_KEY= @@ -109,6 +110,7 @@ requiring the user to enter an API key | `BEDROCK_SECRET_ACCESS_KEY` | AWS IAM Secret Access Key for Bedrock | Optional, but if set `BEDROCK_ACCESS_KEY_ID` must also be set | | `BEDROCK_SESSION_TOKEN` | AWS Session Token for temporary/STS credentials | Optional | | `BEDROCK_REGION` | AWS region for Bedrock (e.g., `us-east-1`, `us-west-2`, `eu-west-1`) | Optional, defaults to `us-east-1` | +| `BEDROCK_USE_CONTAINER_CREDENTIALS` | Set to `true` to use ambient IAM credentials from the ECS/Fargate container credentials endpoint (task role) when no explicit Bedrock credentials are configured. Lowest priority in the credentials chain | Optional | | `DEEPSEEK_API_KEY` | The API key for Deepseek AI | Optional | | `GEMINI_API_KEY` | The API key for Google AI's Gemini | Optional | | `GROQ_API_KEY` | The API key for Groq Cloud | Optional | diff --git a/src/modules/backend/backend.router.ts b/src/modules/backend/backend.router.ts index 837c64cdb0..7a6abc4c90 100644 --- a/src/modules/backend/backend.router.ts +++ b/src/modules/backend/backend.router.ts @@ -6,6 +6,8 @@ import { createTRPCRouter, publicProcedure } from '~/server/trpc/trpc.server'; import { env } from '~/server/env.server'; import { fetchJsonOrTRPCThrow } from '~/server/trpc/trpc.router.fetchers'; +import { bedrockHasContainerCredentialsEndpoint } from '~/modules/llms/server/bedrock/bedrock.containerCredentials'; + // critical to make sure we `import type` here import type { BackendCapabilities } from './store-backend-capabilities'; @@ -51,7 +53,7 @@ export const backendRouter = createTRPCRouter({ hasLlmAlibaba: !!env.ALIBABA_API_KEY || !!env.ALIBABA_API_HOST, hasLlmAnthropic: !!env.ANTHROPIC_API_KEY, hasLlmAzureOpenAI: !!env.AZURE_OPENAI_API_KEY && !!env.AZURE_OPENAI_API_ENDPOINT, - hasLlmBedrock: !!env.BEDROCK_BEARER_TOKEN || (!!env.BEDROCK_ACCESS_KEY_ID && !!env.BEDROCK_SECRET_ACCESS_KEY), + hasLlmBedrock: !!env.BEDROCK_BEARER_TOKEN || (!!env.BEDROCK_ACCESS_KEY_ID && !!env.BEDROCK_SECRET_ACCESS_KEY) || (!!env.BEDROCK_USE_CONTAINER_CREDENTIALS && bedrockHasContainerCredentialsEndpoint()), hasLlmDeepseek: !!env.DEEPSEEK_API_KEY, hasLlmGemini: !!env.GEMINI_API_KEY, hasLlmGroq: !!env.GROQ_API_KEY, diff --git a/src/modules/llms/server/bedrock/bedrock.access.ts b/src/modules/llms/server/bedrock/bedrock.access.ts index 85601eff1e..634cf7716b 100644 --- a/src/modules/llms/server/bedrock/bedrock.access.ts +++ b/src/modules/llms/server/bedrock/bedrock.access.ts @@ -11,8 +11,9 @@ * UNSUPPORTED: Short-term keys (`bedrock-api-key-...`) only support runtime (not model listing). * - **SigV4**: Traditional IAM credentials signing via aws4fetch * - * Priority: client bearer > client IAM > server bearer > server IAM. - * SigV4 uses explicit AWS credentials only (no credential chain) for Edge Runtime compatibility. + * Priority: client bearer > client IAM > server bearer > server IAM > container credentials (opt-in). + * SigV4 uses explicit AWS credentials only (no SDK credential chain) for Edge Runtime compatibility; + * the optional container-credentials provider is likewise pure-fetch (see bedrock.containerCredentials.ts). */ import * as z from 'zod/v4'; @@ -22,6 +23,8 @@ import { AwsClient } from 'aws4fetch'; import { env } from '~/server/env.server'; +import { bedrockContainerCredentialsOrNull } from './bedrock.containerCredentials'; + // configuration const DEFAULT_BEDROCK_REGION = 'us-east-1'; // default region for Bedrock, used if not provided by client or env @@ -46,8 +49,13 @@ export const bedrockAccessSchema = z.object({ type BedrockAuthBearer = { type: 'bearer'; bearerToken: string; region: string }; type BedrockAuthSigV4 = { type: 'sigv4'; accessKeyId: string; secretAccessKey: string; sessionToken: string | undefined; region: string }; -/** Resolve Bedrock authentication. */ -function _bedrockResolveAuth(access: BedrockAccessSchema): BedrockAuthBearer | BedrockAuthSigV4 { +/** True when the client provided its own credentials (bearer or IAM pair). */ +function _hasClientCredentials(access: BedrockAccessSchema): boolean { + return !!access.bedrockBearerToken || (!!access.bedrockAccessKeyId && !!access.bedrockSecretAccessKey); +} + +/** Resolve Bedrock authentication. Async because ambient container credentials may need a fetch. */ +async function _bedrockResolveAuthAsync(access: BedrockAccessSchema): Promise { // 1. Client bearer token (highest priority) let region = access.bedrockRegion || DEFAULT_BEDROCK_REGION; // client-provided region @@ -67,15 +75,28 @@ function _bedrockResolveAuth(access: BedrockAccessSchema): BedrockAuthBearer | B if (env.BEDROCK_ACCESS_KEY_ID && env.BEDROCK_SECRET_ACCESS_KEY) return { type: 'sigv4', accessKeyId: env.BEDROCK_ACCESS_KEY_ID, secretAccessKey: env.BEDROCK_SECRET_ACCESS_KEY, sessionToken: env.BEDROCK_SESSION_TOKEN || undefined, region }; + // 5. [opt-in] Ambient container credentials (ECS/Fargate task role) - short-lived, cached with refresh + if (env.BEDROCK_USE_CONTAINER_CREDENTIALS) { + const ambient = await bedrockContainerCredentialsOrNull(); + if (ambient) + return { type: 'sigv4', accessKeyId: ambient.accessKeyId, secretAccessKey: ambient.secretAccessKey, sessionToken: ambient.sessionToken, region }; + } + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Missing AWS credentials. Add your Bedrock API Key or IAM Access Key on the UI (Models Setup) or server side (your deployment).', }); } -/** Resolve the Bedrock region from access config. */ +/** + * Resolve the Bedrock region from access config - deliberately sync and credential-free: + * client-provided credentials use the client region, server-side credentials (bearer, IAM + * env vars, or ambient container credentials) use the server region. + */ export function bedrockResolveRegion(access: BedrockAccessSchema): string { - return _bedrockResolveAuth(access).region; + return _hasClientCredentials(access) + ? access.bedrockRegion || DEFAULT_BEDROCK_REGION + : env.BEDROCK_REGION || DEFAULT_BEDROCK_REGION; } @@ -111,7 +132,7 @@ export async function bedrockAccessAsync( body?: object, ): Promise<{ headers: HeadersInit; url: string }> { - const auth = _bedrockResolveAuth(access); + const auth = await _bedrockResolveAuthAsync(access); // -- Bearer token: simple Authorization header -- if (auth.type === 'bearer') diff --git a/src/modules/llms/server/bedrock/bedrock.containerCredentials.ts b/src/modules/llms/server/bedrock/bedrock.containerCredentials.ts new file mode 100644 index 0000000000..c44a24a85b --- /dev/null +++ b/src/modules/llms/server/bedrock/bedrock.containerCredentials.ts @@ -0,0 +1,113 @@ +/** + * Ambient AWS credentials from the container credentials endpoint (ECS/Fargate task roles). + * + * Zero-dependency and Edge Runtime compatible: only `fetch` and env vars are used (no AWS + * SDK, no filesystem, no Node APIs). Opt-in via BEDROCK_USE_CONTAINER_CREDENTIALS=true. + * + * Supported (the "container" credential provider - https://docs.aws.amazon.com/sdkref/latest/guide/feature-container-credentials.html): + * - ECS/Fargate task roles: AWS_CONTAINER_CREDENTIALS_RELATIVE_URI, served by the ECS agent + * at the link-local address http://169.254.170.2 + * - AWS_CONTAINER_CREDENTIALS_FULL_URI, with the optional static AWS_CONTAINER_AUTHORIZATION_TOKEN + * + * NOT supported (would need more machinery than a fetch): + * - EC2 IMDSv2 (different token+discovery protocol) + * - EKS IRSA (requires an STS AssumeRoleWithWebIdentity call) + * - EKS Pod Identity (AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE rotates on disk; the Edge Runtime has no fs) + * + * Lifecycle: task-role credentials rotate (~hours), so we cache at module level, refresh with + * a safety margin before expiration, single-flight concurrent refreshes, and keep serving the + * cached credentials on refresh failures for as long as they are still valid. + */ + +// configuration +const _ECS_CREDENTIALS_HOST = 'http://169.254.170.2'; // ECS agent, link-local +const _EXPIRY_MARGIN_MS = 5 * 60 * 1000; // refresh 5 minutes before expiration +const _FETCH_TIMEOUT_MS = 5 * 1000; // generous - the endpoint is link-local (measured <100ms) + + +export interface BedrockContainerCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string | undefined; // always present on ECS task roles, but not required for signing + expiresAt: number; // epoch ms +} + +// module-level cache - persists across invocations within the server (or Edge sandbox) instance +let _cache: BedrockContainerCredentials | null = null; +let _inflightRefresh: Promise | null = null; +let _loggedFirstUse = false; + + +/** True when the runtime exposes a container credentials endpoint (e.g. ECS/Fargate with a task role). */ +export function bedrockHasContainerCredentialsEndpoint(): boolean { + return !!process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI; +} + +/** + * Returns valid (cached or freshly fetched) container credentials, or null when unavailable, + * letting the caller fall through to its standard missing-credentials error. + */ +export async function bedrockContainerCredentialsOrNull(): Promise { + + // fresh cache hit + if (_cache && Date.now() < _cache.expiresAt - _EXPIRY_MARGIN_MS) + return _cache; + + // single-flight: concurrent requests await the same refresh + if (!_inflightRefresh) + _inflightRefresh = _fetchContainerCredentials().finally(() => _inflightRefresh = null); + + try { + _cache = await _inflightRefresh; + return _cache; + } catch (error: any) { + // stale-tolerant: on refresh failure keep serving cached credentials until they actually expire + if (_cache && Date.now() < _cache.expiresAt) + return _cache; + console.warn('[Bedrock] container credentials unavailable:', error?.message || error); + return null; + } +} + +async function _fetchContainerCredentials(): Promise { + + // resolve the endpoint + const relativeUri = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI; + const url = relativeUri ? _ECS_CREDENTIALS_HOST + relativeUri : process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI; + if (!url) + throw new Error('no container credentials endpoint (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI/_FULL_URI unset)'); + + // optional static authorization token (the rotating _TOKEN_FILE variant is not supported - see header) + const authToken = process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN; + + const response = await fetch(url, { + headers: { + 'Accept': 'application/json', + ...(authToken ? { 'Authorization': authToken } : {}), + }, + signal: AbortSignal.timeout(_FETCH_TIMEOUT_MS), + }); + if (!response.ok) + throw new Error(`credentials endpoint returned HTTP ${response.status}`); + + const wireCreds = await response.json() as { RoleArn?: string; AccessKeyId?: string; SecretAccessKey?: string; Token?: string; Expiration?: string }; + if (!wireCreds?.AccessKeyId || !wireCreds.SecretAccessKey || !wireCreds.Expiration) + throw new Error('credentials endpoint returned an unexpected payload'); + + const expiresAt = Date.parse(wireCreds.Expiration); + if (isNaN(expiresAt)) + throw new Error(`credentials endpoint returned an invalid Expiration: ${wireCreds.Expiration}`); + + // log once, as operational evidence of the ambient auth mode + if (!_loggedFirstUse) { + _loggedFirstUse = true; + console.log(`[Bedrock] using container credentials${wireCreds.RoleArn ? ` (role: ${wireCreds.RoleArn})` : ''}, expiring ${wireCreds.Expiration}`); + } + + return { + accessKeyId: wireCreds.AccessKeyId, + secretAccessKey: wireCreds.SecretAccessKey, + sessionToken: wireCreds.Token || undefined, + expiresAt, + }; +} diff --git a/src/server/env.server.ts b/src/server/env.server.ts index 084fe667a3..fcae1767d2 100644 --- a/src/server/env.server.ts +++ b/src/server/env.server.ts @@ -59,6 +59,7 @@ export const env = createEnv({ BEDROCK_SECRET_ACCESS_KEY: z.string().optional(), BEDROCK_SESSION_TOKEN: z.string().optional(), // required with the other 2 on corporate accounts sometimes BEDROCK_REGION: z.string().optional(), + BEDROCK_USE_CONTAINER_CREDENTIALS: z.enum(['true']).optional(), // opt-in: ambient IAM via the ECS/Fargate container credentials endpoint (task role), lowest priority // LLM: Cerebras CEREBRAS_API_KEY: z.string().optional(),