Skip to content

Commit 0793136

Browse files
wirjovincentkoc
andauthored
feat(bedrock-mantle): add IAM credential auth via @aws/bedrock-token-… (openclaw#61563)
* feat(bedrock-mantle): add IAM credential auth via @aws/bedrock-token-generator Mantle previously required a manually-created API key (AWS_BEARER_TOKEN_BEDROCK). This adds automatic bearer token generation from IAM credentials using the official @aws/bedrock-token-generator package. Auth priority: 1. Explicit AWS_BEARER_TOKEN_BEDROCK env var (manual API key from Console) 2. IAM credentials via getTokenProvider() → Bearer token (instance roles, SSO profiles, access keys, EKS IRSA, ECS task roles) Token is cached in memory (1hr TTL, generated with 2hr validity) and in process.env.AWS_BEARER_TOKEN_BEDROCK for downstream sync reads. Falls back gracefully when package is not installed or credentials are unavailable — Mantle provider simply not registered. Closes openclaw#45152 * fix(bedrock-mantle): harden IAM auth --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
1 parent 2985fc0 commit 0793136

8 files changed

Lines changed: 311 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
4040
- Memory/dreaming: write dreaming trail content to top-level `dreams.md` instead of daily memory notes, update `/dreaming` help text to point there, and keep `dreams.md` available for explicit reads without pulling it into default recall. Thanks @davemorin.
4141
- Memory/dreaming: add the Dream Diary surface in Dreams, simplify user-facing dreaming config to `enabled` plus optional `frequency`, treat phases as implementation detail in docs/UI, and keep the lobster animation visible above diary content. Thanks @vignesh07.
4242
- Memory/search: add Amazon Bedrock embeddings for Titan, Cohere, Nova, and TwelveLabs models, with AWS credential-chain auto-detection for `provider: "auto"` and provider-specific dimension controls. Thanks @wirjo.
43+
- Providers/Amazon Bedrock Mantle: generate bearer tokens from the AWS credential chain so Mantle auto-discovery can use IAM auth without manually exporting `AWS_BEARER_TOKEN_BEDROCK`. Thanks @wirjo.
4344

4445
### Fixes
4546

docs/providers/bedrock-mantle.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,42 @@ third-party models (GPT-OSS, Qwen, Kimi, GLM, and similar) through a standard
1717

1818
- Provider: `amazon-bedrock-mantle`
1919
- API: `openai-completions` (OpenAI-compatible)
20-
- Auth: bearer token via `AWS_BEARER_TOKEN_BEDROCK`
20+
- Auth: explicit `AWS_BEARER_TOKEN_BEDROCK` or IAM credential-chain bearer-token generation
2121
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
2222

2323
## Automatic model discovery
2424

25-
When `AWS_BEARER_TOKEN_BEDROCK` is set, OpenClaw automatically discovers
26-
available Mantle models by querying the region's `/v1/models` endpoint.
27-
Discovery results are cached for 1 hour.
25+
When `AWS_BEARER_TOKEN_BEDROCK` is set, OpenClaw uses it directly. Otherwise,
26+
OpenClaw attempts to generate a Mantle bearer token from the AWS default
27+
credential chain, including shared credentials/config profiles, SSO, web
28+
identity, and instance or task roles. It then discovers available Mantle
29+
models by querying the region's `/v1/models` endpoint. Discovery results are
30+
cached for 1 hour, and IAM-derived bearer tokens are refreshed hourly.
2831

2932
Supported regions: `us-east-1`, `us-east-2`, `us-west-2`, `ap-northeast-1`,
3033
`ap-south-1`, `ap-southeast-3`, `eu-central-1`, `eu-west-1`, `eu-west-2`,
3134
`eu-south-1`, `eu-north-1`, `sa-east-1`.
3235

3336
## Onboarding
3437

35-
1. Set the bearer token on the **gateway host**:
38+
1. Choose one auth path on the **gateway host**:
39+
40+
Explicit bearer token:
3641

3742
```bash
3843
export AWS_BEARER_TOKEN_BEDROCK="..."
3944
# Optional (defaults to us-east-1):
4045
export AWS_REGION="us-west-2"
4146
```
4247

48+
IAM credentials:
49+
50+
```bash
51+
# Any AWS SDK-compatible auth source works here, for example:
52+
export AWS_PROFILE="default"
53+
export AWS_REGION="us-west-2"
54+
```
55+
4356
2. Verify models are discovered:
4457

4558
```bash
@@ -81,8 +94,8 @@ If you prefer explicit config instead of auto-discovery:
8194

8295
## Notes
8396

84-
- Mantle requires a bearer token today. Plain IAM credentials (instance roles,
85-
SSO, access keys) are not sufficient without a token.
97+
- OpenClaw can mint the Mantle bearer token for you from AWS SDK-compatible
98+
IAM credentials when `AWS_BEARER_TOKEN_BEDROCK` is not set.
8699
- The bearer token is the same `AWS_BEARER_TOKEN_BEDROCK` used by the standard
87100
[Amazon Bedrock](/providers/bedrock) provider.
88101
- Reasoning support is inferred from model IDs containing patterns like

extensions/amazon-bedrock-mantle/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export {
22
discoverMantleModels,
3+
generateBearerTokenFromIam,
34
mergeImplicitMantleProvider,
5+
resetIamTokenCacheForTest,
46
resetMantleDiscoveryCacheForTest,
57
resolveImplicitMantleProvider,
68
resolveMantleBearerToken,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
declare module "@aws/bedrock-token-generator" {
2+
export function getTokenProvider(opts?: {
3+
region?: string;
4+
expiresInSeconds?: number;
5+
}): () => Promise<string>;
6+
}

extensions/amazon-bedrock-mantle/discovery.test.ts

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,31 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import {
33
discoverMantleModels,
4+
generateBearerTokenFromIam,
45
mergeImplicitMantleProvider,
6+
resetIamTokenCacheForTest,
57
resetMantleDiscoveryCacheForTest,
68
resolveMantleBearerToken,
79
resolveImplicitMantleProvider,
810
} from "./api.js";
911

12+
const mocks = vi.hoisted(() => ({
13+
getTokenProvider: vi.fn(),
14+
}));
15+
16+
vi.mock("@aws/bedrock-token-generator", () => ({
17+
getTokenProvider: mocks.getTokenProvider,
18+
}));
19+
1020
describe("bedrock mantle discovery", () => {
1121
const originalEnv = process.env;
1222

1323
beforeEach(() => {
1424
process.env = { ...originalEnv };
1525
vi.restoreAllMocks();
26+
mocks.getTokenProvider.mockReset();
1627
resetMantleDiscoveryCacheForTest();
28+
resetIamTokenCacheForTest();
1729
});
1830

1931
afterEach(() => {
@@ -44,6 +56,68 @@ describe("bedrock mantle discovery", () => {
4456
).toBe("my-token");
4557
});
4658

59+
// ---------------------------------------------------------------------------
60+
// IAM token generation
61+
// ---------------------------------------------------------------------------
62+
63+
it("generates token from IAM credentials when token generation succeeds", async () => {
64+
const tokenProvider = vi.fn(async () => "bedrock-api-key-generated"); // pragma: allowlist secret
65+
mocks.getTokenProvider.mockReturnValue(tokenProvider);
66+
67+
const token = await generateBearerTokenFromIam({ region: "us-east-1" });
68+
69+
expect(token).toBe("bedrock-api-key-generated");
70+
expect(mocks.getTokenProvider).toHaveBeenCalledWith({
71+
region: "us-east-1",
72+
expiresInSeconds: 7200,
73+
});
74+
expect(tokenProvider).toHaveBeenCalledTimes(1);
75+
});
76+
77+
it("caches generated IAM tokens within TTL", async () => {
78+
const tokenProvider = vi.fn(async () => "bedrock-api-key-cached"); // pragma: allowlist secret
79+
mocks.getTokenProvider.mockReturnValue(tokenProvider);
80+
let now = 1000;
81+
82+
const t1 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
83+
now += 1800_000; // 30 min — within 1hr cache TTL
84+
const t2 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
85+
86+
expect(t1).toEqual(t2);
87+
expect(tokenProvider).toHaveBeenCalledTimes(1);
88+
});
89+
90+
it("does not reuse an IAM token across regions", async () => {
91+
const tokenProvider = vi
92+
.fn<() => Promise<string>>()
93+
.mockResolvedValueOnce("bedrock-api-key-east") // pragma: allowlist secret
94+
.mockResolvedValueOnce("bedrock-api-key-west"); // pragma: allowlist secret
95+
mocks.getTokenProvider.mockReturnValue(tokenProvider);
96+
97+
const east = await generateBearerTokenFromIam({ region: "us-east-1", now: () => 1000 });
98+
const west = await generateBearerTokenFromIam({ region: "us-west-2", now: () => 2000 });
99+
100+
expect(east).toBe("bedrock-api-key-east");
101+
expect(west).toBe("bedrock-api-key-west");
102+
expect(mocks.getTokenProvider).toHaveBeenNthCalledWith(1, {
103+
region: "us-east-1",
104+
expiresInSeconds: 7200,
105+
});
106+
expect(mocks.getTokenProvider).toHaveBeenNthCalledWith(2, {
107+
region: "us-west-2",
108+
expiresInSeconds: 7200,
109+
});
110+
expect(tokenProvider).toHaveBeenCalledTimes(2);
111+
});
112+
113+
it("returns undefined when IAM token generation fails", async () => {
114+
mocks.getTokenProvider.mockImplementation(() => {
115+
throw new Error("no credentials");
116+
});
117+
118+
await expect(generateBearerTokenFromIam({ region: "us-east-1" })).resolves.toBeUndefined();
119+
});
120+
47121
// ---------------------------------------------------------------------------
48122
// Model discovery
49123
// ---------------------------------------------------------------------------
@@ -278,23 +352,47 @@ describe("bedrock mantle discovery", () => {
278352
expect(provider?.models).toHaveLength(1);
279353
});
280354

281-
it("returns null when no bearer token is available", async () => {
355+
it("returns null when no auth is available", async () => {
356+
mocks.getTokenProvider.mockImplementation(() => {
357+
throw new Error("no credentials");
358+
});
359+
282360
const provider = await resolveImplicitMantleProvider({
283361
env: {} as NodeJS.ProcessEnv,
284362
});
285363

286364
expect(provider).toBeNull();
287365
});
288366

289-
it("does not infer Mantle auth from plain IAM env vars alone", async () => {
367+
it("uses a generated IAM token when no explicit token is set", async () => {
368+
const tokenProvider = vi.fn(async () => "bedrock-api-key-iam"); // pragma: allowlist secret
369+
const mockFetch = vi.fn().mockResolvedValue({
370+
ok: true,
371+
json: async () => ({
372+
data: [{ id: "openai.gpt-oss-120b", object: "model" }],
373+
}),
374+
});
375+
mocks.getTokenProvider.mockReturnValue(tokenProvider);
376+
290377
const provider = await resolveImplicitMantleProvider({
291378
env: {
292379
AWS_PROFILE: "default",
293380
AWS_REGION: "us-east-1",
294381
} as NodeJS.ProcessEnv,
382+
fetchFn: mockFetch as unknown as typeof fetch,
295383
});
296384

297-
expect(provider).toBeNull();
385+
expect(provider).not.toBeNull();
386+
expect(provider?.apiKey).toBe("bedrock-api-key-iam");
387+
expect(tokenProvider).toHaveBeenCalledTimes(1);
388+
expect(mockFetch).toHaveBeenCalledWith(
389+
"https://bedrock-mantle.us-east-1.api.aws/v1/models",
390+
expect.objectContaining({
391+
headers: expect.objectContaining({
392+
Authorization: "Bearer bedrock-api-key-iam",
393+
}),
394+
}),
395+
);
298396
});
299397

300398
it("returns null for unsupported regions", async () => {

extensions/amazon-bedrock-mantle/discovery.ts

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,8 @@ export type MantleBearerTokenProvider = () => Promise<string>;
5454
* Resolve a bearer token for Mantle authentication.
5555
*
5656
* Returns the value of AWS_BEARER_TOKEN_BEDROCK if set, undefined otherwise.
57-
*
58-
* Mantle's OpenAI-compatible surface expects a bearer token today in OpenClaw.
59-
* Plain IAM credentials (instance roles, SSO, access keys) are not enough
60-
* until we wire in SigV4-derived token generation via `@aws/bedrock-token-generator`.
57+
* When no explicit token is set, `resolveImplicitMantleProvider` will attempt
58+
* to generate one from IAM credentials via `@aws/bedrock-token-generator`.
6159
*/
6260
export function resolveMantleBearerToken(env: NodeJS.ProcessEnv = process.env): string | undefined {
6361
const explicitToken = env.AWS_BEARER_TOKEN_BEDROCK?.trim();
@@ -67,6 +65,54 @@ export function resolveMantleBearerToken(env: NodeJS.ProcessEnv = process.env):
6765
return undefined;
6866
}
6967

68+
/** Token cache for IAM-derived bearer tokens, keyed by region. */
69+
const iamTokenCache = new Map<string, { token: string; expiresAt: number }>();
70+
const IAM_TOKEN_TTL_MS = 3600_000; // Refresh every 1 hour (tokens valid up to 12h)
71+
72+
/**
73+
* Generate a bearer token from IAM credentials using `@aws/bedrock-token-generator`.
74+
*
75+
* Uses the AWS default credential chain (instance roles, SSO, access keys, EKS IRSA).
76+
* Returns undefined if the package is not installed or credentials are unavailable.
77+
*/
78+
export async function generateBearerTokenFromIam(params: {
79+
region: string;
80+
now?: () => number;
81+
}): Promise<string | undefined> {
82+
const now = params.now?.() ?? Date.now();
83+
const cached = iamTokenCache.get(params.region);
84+
85+
if (cached && cached.expiresAt > now) {
86+
return cached.token;
87+
}
88+
89+
try {
90+
const { getTokenProvider } = (await import("@aws/bedrock-token-generator")) as {
91+
getTokenProvider: (opts?: {
92+
region?: string;
93+
expiresInSeconds?: number;
94+
}) => () => Promise<string>;
95+
};
96+
const token = await getTokenProvider({
97+
region: params.region,
98+
expiresInSeconds: 7200, // 2 hours
99+
})();
100+
iamTokenCache.set(params.region, { token, expiresAt: now + IAM_TOKEN_TTL_MS });
101+
return token;
102+
} catch (error) {
103+
log.debug?.("Mantle IAM token generation unavailable", {
104+
region: params.region,
105+
error: error instanceof Error ? error.message : String(error),
106+
});
107+
return undefined;
108+
}
109+
}
110+
111+
/** Reset the IAM token cache (for testing). */
112+
export function resetIamTokenCacheForTest(): void {
113+
iamTokenCache.clear();
114+
}
115+
70116
// ---------------------------------------------------------------------------
71117
// OpenAI-format model list response
72118
// ---------------------------------------------------------------------------
@@ -198,10 +244,11 @@ export async function discoverMantleModels(params: {
198244
// ---------------------------------------------------------------------------
199245

200246
/**
201-
* Resolve an implicit Bedrock Mantle provider if bearer-token auth is available.
247+
* Resolve an implicit Bedrock Mantle provider if authentication is available.
202248
*
203-
* Detection:
204-
* - AWS_BEARER_TOKEN_BEDROCK is set → Mantle is available
249+
* Detection priority:
250+
* 1. AWS_BEARER_TOKEN_BEDROCK env var → use directly
251+
* 2. IAM credentials → generate bearer token via `@aws/bedrock-token-generator`
205252
* - Region from AWS_REGION / AWS_DEFAULT_REGION / default us-east-1
206253
* - Models discovered from `/v1/models`
207254
*/
@@ -210,16 +257,18 @@ export async function resolveImplicitMantleProvider(params: {
210257
fetchFn?: typeof fetch;
211258
}): Promise<ModelProviderConfig | null> {
212259
const env = params.env ?? process.env;
213-
const bearerToken = resolveMantleBearerToken(env);
260+
const region = env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
261+
const explicitBearerToken = resolveMantleBearerToken(env);
214262

215-
if (!bearerToken) {
263+
if (!isSupportedRegion(region)) {
264+
log.debug?.("Mantle not available in region", { region });
216265
return null;
217266
}
218267

219-
const region = env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
268+
// Try explicit token first, then generate from IAM credentials
269+
const bearerToken = explicitBearerToken ?? (await generateBearerTokenFromIam({ region }));
220270

221-
if (!isSupportedRegion(region)) {
222-
log.debug?.("Mantle not available in region", { region });
271+
if (!bearerToken) {
223272
return null;
224273
}
225274

@@ -233,11 +282,13 @@ export async function resolveImplicitMantleProvider(params: {
233282
return null;
234283
}
235284

285+
log.debug?.("Mantle provider resolved", { region, modelCount: models.length });
286+
236287
return {
237288
baseUrl: `${mantleEndpoint(region)}/v1`,
238289
api: "openai-completions",
239290
auth: "api-key",
240-
apiKey: "env:AWS_BEARER_TOKEN_BEDROCK",
291+
apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : bearerToken,
241292
models,
242293
};
243294
}

extensions/amazon-bedrock-mantle/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
"private": true,
55
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
66
"type": "module",
7+
"dependencies": {
8+
"@aws/bedrock-token-generator": "^1.1.0"
9+
},
710
"openclaw": {
811
"bundle": {
912
"stageRuntimeDependencies": true

0 commit comments

Comments
 (0)