Skip to content

Commit 3f807bb

Browse files
0xSolacewakesync
andcommitted
feat(cloud): mint steward agent JWTs
Co-authored-by: wakesync <shadow@shad0w.xyz>
1 parent 4694935 commit 3f807bb

7 files changed

Lines changed: 405 additions & 4 deletions

File tree

packages/cloud-api/.well-known/jwks.json/route.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,27 @@
44
*/
55

66
import { Hono } from "hono";
7+
import {
8+
getAgentTokenJWKS,
9+
isAgentTokenSigningConfigured,
10+
} from "@/lib/auth/agent-token";
711
import { getJWKS, isJWKSConfigured } from "@/lib/auth/jwks";
812
import type { AppEnv } from "@/types/cloud-worker-env";
913

1014
const app = new Hono<AppEnv>();
1115

1216
app.get("/", async (c) => {
13-
if (!isJWKSConfigured()) {
17+
const keys = [];
18+
if (isJWKSConfigured()) {
19+
keys.push(...(await getJWKS()).keys);
20+
}
21+
if (isAgentTokenSigningConfigured()) {
22+
keys.push(...(await getAgentTokenJWKS()).keys);
23+
}
24+
if (keys.length === 0) {
1425
return c.json({ error: "JWKS not configured" }, 503);
1526
}
16-
const jwks = await getJWKS();
17-
return c.json(jwks, 200, {
27+
return c.json({ keys }, 200, {
1828
"Cache-Control": "public, max-age=300",
1929
"Content-Type": "application/json",
2030
});

packages/cloud-api/src/_router.generated.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* AUTO-GENERATED by src/_generate-router.mjs - do not edit by hand.
33
* Re-run `bun run codegen` after adding or removing a route.ts file.
44
*
5-
* 560 routes mounted, 0 skipped (still Next-shaped).
5+
* 561 routes mounted, 0 skipped (still Next-shaped).
66
*/
77

88
/* eslint-disable */
@@ -238,6 +238,7 @@ import _route_v1_advertising_campaigns_route from "../v1/advertising/campaigns/r
238238
import _route_v1_advertising_creatives_p_id_route from "../v1/advertising/creatives/[id]/route";
239239
import _route_v1_affiliates_link_route from "../v1/affiliates/link/route";
240240
import _route_v1_affiliates_route from "../v1/affiliates/route";
241+
import _route_v1_agent_tokens_route from "../v1/agent-tokens/route";
241242
import _route_v1_agents_by_token_route from "../v1/agents/by-token/route";
242243
import _route_v1_agents_p_agentId_logs_route from "../v1/agents/[agentId]/logs/route";
243244
import _route_v1_agents_p_agentId_monetization_route from "../v1/agents/[agentId]/monetization/route";
@@ -1157,6 +1158,7 @@ export function mountRoutes(app: Hono<AppEnv>): void {
11571158
);
11581159
app.route("/api/v1/affiliates/link", _route_v1_affiliates_link_route);
11591160
app.route("/api/v1/affiliates", _route_v1_affiliates_route);
1161+
app.route("/api/v1/agent-tokens", _route_v1_agent_tokens_route);
11601162
app.route("/api/v1/agents/by-token", _route_v1_agents_by_token_route);
11611163
app.route(
11621164
"/api/v1/agents/:agentId/logs",

packages/cloud-api/src/bootstrap-app.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { runWithCloudBindingsAsync } from "@/lib/runtime/cloud-bindings";
1616
import { setRuntimeR2Bucket } from "@/lib/storage/r2-runtime-binding";
1717
import { logger } from "@/lib/utils/logger";
1818
import type { AppEnv } from "@/types/cloud-worker-env";
19+
import jwksRoute from "../.well-known/jwks.json/route";
1920
import { handleBlueBubblesWebhook } from "../webhooks/bluebubbles/route";
2021
import { mountRoutes } from "./_router.generated";
2122
import { authMiddleware } from "./middleware/auth";
@@ -68,6 +69,8 @@ export function createApp(): Hono<AppEnv> {
6869
},
6970
);
7071
});
72+
app.route("/.well-known/jwks.json", jwksRoute);
73+
7174
app.use("*", authMiddleware);
7275

7376
app.all("/steward", embeddedStewardHandler);

packages/cloud-api/src/middleware/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const publicPathPrefixes = [
4949
"/api/v1/models",
5050
"/api/v1/pricing/summary",
5151
"/api/v1/agents/by-token",
52+
"/api/v1/agent-tokens",
5253
"/api/v1/credits/topup",
5354
"/api/v1/topup",
5455
"/api/v1/x402",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* POST /api/v1/agent-tokens
3+
* Mints a short-lived, RS256 Steward agent JWT for a cloud-provisioned agent.
4+
*
5+
* Auth: service-account bearer/header token, or an authenticated admin user.
6+
* Body: { agentId: string; ttl?: number }
7+
* Response: { token, expiresAt }
8+
*/
9+
10+
import { Hono } from "hono";
11+
import { mintAgentToken } from "@/lib/auth/agent-token";
12+
import { getCurrentUser } from "@/lib/auth/workers-hono-auth";
13+
import { logger } from "@/lib/utils/logger";
14+
import type { AppContext, AppEnv } from "@/types/cloud-worker-env";
15+
16+
const app = new Hono<AppEnv>();
17+
18+
function bearerToken(c: {
19+
req: { header: (name: string) => string | undefined };
20+
}): string | null {
21+
const auth = c.req.header("authorization");
22+
return auth?.startsWith("Bearer ") ? auth.slice(7) : null;
23+
}
24+
25+
function serviceToken(c: AppEnv["Bindings"]): string | null {
26+
const candidates = [c.ELIZA_CLOUD_SERVICE_TOKEN, c.AGENT_TOKEN_SERVICE_TOKEN];
27+
for (const candidate of candidates) {
28+
if (typeof candidate === "string" && candidate.trim())
29+
return candidate.trim();
30+
}
31+
return null;
32+
}
33+
34+
function hasServiceAccountAuth(c: AppContext): boolean {
35+
const expected = serviceToken(c.env);
36+
if (!expected) return false;
37+
const supplied =
38+
bearerToken(c) ??
39+
c.req.header("x-eliza-service-token") ??
40+
c.req.header("x-service-token");
41+
return supplied === expected;
42+
}
43+
44+
async function hasAdminAuth(c: AppContext): Promise<boolean> {
45+
const existing = c.get("user");
46+
if (existing?.role === "admin") return true;
47+
const user = await getCurrentUser(c).catch(() => null);
48+
return user?.role === "admin";
49+
}
50+
51+
app.post("/", async (c) => {
52+
const serviceAccount = hasServiceAccountAuth(c);
53+
const admin = serviceAccount ? false : await hasAdminAuth(c);
54+
if (!serviceAccount && !admin) {
55+
return c.json(
56+
{
57+
success: false,
58+
error: "admin or container service-account auth required",
59+
},
60+
401,
61+
);
62+
}
63+
64+
const body = (await c.req.json().catch(() => ({}))) as {
65+
agentId?: unknown;
66+
ttl?: unknown;
67+
};
68+
const agentId = typeof body.agentId === "string" ? body.agentId : "";
69+
if (!agentId.trim()) {
70+
return c.json({ success: false, error: "agentId is required" }, 400);
71+
}
72+
73+
try {
74+
const minted = await mintAgentToken(agentId, body.ttl);
75+
logger.info("[agent-token] minted Steward JWT", {
76+
agentId: agentId.trim(),
77+
expiresAt: minted.expiresAt,
78+
actor: serviceAccount ? "service-account" : "admin",
79+
});
80+
return c.json(minted);
81+
} catch (error) {
82+
const message = error instanceof Error ? error.message : String(error);
83+
if (message === "invalid agentId") {
84+
return c.json({ success: false, error: message }, 400);
85+
}
86+
if (message.includes("AGENT_TOKEN_PRIVATE_KEY_PEM")) {
87+
return c.json(
88+
{ success: false, error: "agent-token signing key is not configured" },
89+
503,
90+
);
91+
}
92+
logger.error("[agent-token] failed to mint Steward JWT", { error });
93+
return c.json({ success: false, error: "failed to mint agent token" }, 500);
94+
}
95+
});
96+
97+
export default app;
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { calculateJwkThumbprint, exportJWK, importPKCS8, type JWK, SignJWT } from "jose";
2+
3+
import { getCloudAwareEnv } from "../runtime/cloud-bindings";
4+
5+
export type AgentTokenMintResult = {
6+
token: string;
7+
expiresAt: string;
8+
};
9+
10+
const DEFAULT_TTL_SECONDS = 15 * 60;
11+
const MIN_TTL_SECONDS = 60;
12+
const MAX_TTL_SECONDS = 60 * 60;
13+
const ISSUER = "eliza-cloud";
14+
const AUDIENCE = "steward";
15+
const ALGORITHM = "RS256";
16+
17+
let cachedPrivateKey: CryptoKey | null = null;
18+
let cachedPrivateKeySource: string | null = null;
19+
let cachedPublicJwk: JWK | null = null;
20+
let cachedPublicJwkSource: string | null = null;
21+
let cachedKeyId: string | null = null;
22+
let cachedKeyIdSource: string | null = null;
23+
24+
function envString(name: string): string | undefined {
25+
const value = getCloudAwareEnv()[name];
26+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
27+
}
28+
29+
function normalizePem(value: string): string {
30+
const trimmed = value.trim().replace(/\\n/g, "\n");
31+
if (trimmed.includes("-----BEGIN")) return trimmed;
32+
return `-----BEGIN PRIVATE KEY-----\n${trimmed}\n-----END PRIVATE KEY-----`;
33+
}
34+
35+
function getPrivateKeyPem(): string | undefined {
36+
const raw =
37+
envString("AGENT_TOKEN_PRIVATE_KEY_PEM") ?? envString("ELIZA_AGENT_TOKEN_PRIVATE_KEY_PEM");
38+
return raw ? normalizePem(raw) : undefined;
39+
}
40+
41+
export function isAgentTokenSigningConfigured(): boolean {
42+
return Boolean(getPrivateKeyPem());
43+
}
44+
45+
export function normalizeAgentTokenTtl(ttl?: unknown): number {
46+
const requested =
47+
typeof ttl === "number" && Number.isFinite(ttl) ? Math.floor(ttl) : DEFAULT_TTL_SECONDS;
48+
return Math.min(Math.max(requested, MIN_TTL_SECONDS), MAX_TTL_SECONDS);
49+
}
50+
51+
function normalizeAgentId(agentId: string): string {
52+
const normalized = agentId.trim();
53+
if (!/^[a-zA-Z0-9_.:-]{1,128}$/.test(normalized)) {
54+
throw new Error("invalid agentId");
55+
}
56+
return normalized;
57+
}
58+
59+
async function getAgentTokenPrivateKey(): Promise<CryptoKey> {
60+
const pem = getPrivateKeyPem();
61+
if (!pem) {
62+
throw new Error("AGENT_TOKEN_PRIVATE_KEY_PEM is not configured");
63+
}
64+
if (cachedPrivateKey && cachedPrivateKeySource === pem) return cachedPrivateKey;
65+
cachedPrivateKey = await importPKCS8(pem, ALGORITHM, { extractable: true });
66+
cachedPrivateKeySource = pem;
67+
cachedPublicJwk = null;
68+
cachedPublicJwkSource = null;
69+
cachedKeyId = null;
70+
cachedKeyIdSource = null;
71+
return cachedPrivateKey;
72+
}
73+
74+
async function exportedPublicJwkForCurrentKey(): Promise<JWK> {
75+
const privateKey = await getAgentTokenPrivateKey();
76+
const jwk = await exportJWK(privateKey);
77+
// Strip private RSA parameters before exposing the public JWK.
78+
delete jwk.d;
79+
delete jwk.p;
80+
delete jwk.q;
81+
delete jwk.dp;
82+
delete jwk.dq;
83+
delete jwk.qi;
84+
delete (jwk as Record<string, unknown>).oth;
85+
return jwk;
86+
}
87+
88+
export async function getAgentTokenKeyId(): Promise<string> {
89+
const configured = envString("AGENT_TOKEN_KEY_ID") ?? envString("ELIZA_AGENT_TOKEN_KEY_ID");
90+
if (configured) return configured;
91+
92+
const pem = getPrivateKeyPem();
93+
if (!pem) {
94+
throw new Error("AGENT_TOKEN_PRIVATE_KEY_PEM is not configured");
95+
}
96+
if (cachedKeyId && cachedKeyIdSource === pem) return cachedKeyId;
97+
98+
cachedKeyId = (
99+
await calculateJwkThumbprint(await exportedPublicJwkForCurrentKey(), "sha256")
100+
).slice(0, 16);
101+
cachedKeyIdSource = pem;
102+
return cachedKeyId;
103+
}
104+
105+
export async function getAgentTokenPublicJwk(): Promise<JWK> {
106+
const pem = getPrivateKeyPem();
107+
if (!pem) {
108+
throw new Error("AGENT_TOKEN_PRIVATE_KEY_PEM is not configured");
109+
}
110+
if (cachedPublicJwk && cachedPublicJwkSource === pem) return cachedPublicJwk;
111+
112+
const jwk = await exportedPublicJwkForCurrentKey();
113+
jwk.kid = await getAgentTokenKeyId();
114+
jwk.alg = ALGORITHM;
115+
jwk.use = "sig";
116+
117+
cachedPublicJwk = jwk;
118+
cachedPublicJwkSource = pem;
119+
return jwk;
120+
}
121+
122+
export async function getAgentTokenJWKS(): Promise<{ keys: JWK[] }> {
123+
return { keys: [await getAgentTokenPublicJwk()] };
124+
}
125+
126+
export async function mintAgentToken(
127+
agentId: string,
128+
ttl?: unknown,
129+
): Promise<AgentTokenMintResult> {
130+
const normalizedAgentId = normalizeAgentId(agentId);
131+
const ttlSeconds = normalizeAgentTokenTtl(ttl);
132+
const issuedAt = Math.floor(Date.now() / 1000);
133+
const expiresAtSeconds = issuedAt + ttlSeconds;
134+
const privateKey = await getAgentTokenPrivateKey();
135+
136+
const token = await new SignJWT({ agent_id: normalizedAgentId })
137+
.setProtectedHeader({ alg: ALGORITHM, typ: "JWT", kid: await getAgentTokenKeyId() })
138+
.setSubject(`agent:${normalizedAgentId}`)
139+
.setIssuer(ISSUER)
140+
.setAudience(AUDIENCE)
141+
.setIssuedAt(issuedAt)
142+
.setNotBefore(issuedAt)
143+
.setExpirationTime(expiresAtSeconds)
144+
.sign(privateKey);
145+
146+
return { token, expiresAt: new Date(expiresAtSeconds * 1000).toISOString() };
147+
}

0 commit comments

Comments
 (0)