@slashfi/agents-sdk 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-definitions/auth.d.ts +13 -0
- package/dist/agent-definitions/auth.d.ts.map +1 -1
- package/dist/agent-definitions/auth.js +92 -18
- package/dist/agent-definitions/auth.js.map +1 -1
- package/dist/agent-definitions/integrations.d.ts.map +1 -1
- package/dist/agent-definitions/integrations.js +18 -2
- package/dist/agent-definitions/integrations.js.map +1 -1
- package/dist/agent-definitions/remote-registry.d.ts.map +1 -1
- package/dist/agent-definitions/remote-registry.js +15 -3
- package/dist/agent-definitions/remote-registry.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/key-manager.d.ts +76 -0
- package/dist/key-manager.d.ts.map +1 -0
- package/dist/key-manager.js +156 -0
- package/dist/key-manager.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +21 -9
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-definitions/auth.ts +125 -18
- package/src/agent-definitions/integrations.ts +8 -2
- package/src/agent-definitions/remote-registry.ts +12 -3
- package/src/index.ts +1 -0
- package/src/key-manager.test.ts +273 -0
- package/src/key-manager.ts +257 -0
- package/src/server.ts +24 -9
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWKS Key Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages ES256 signing keys with automatic rotation and revocation.
|
|
5
|
+
* Store-agnostic — provide a KeyStore implementation for your DB.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Automatic key rotation on a configurable schedule
|
|
9
|
+
* - Key lifecycle: active → deprecated → revoked → cleaned up
|
|
10
|
+
* - Multi-instance safe: checks DB before rotating (another instance may have already rotated)
|
|
11
|
+
* - Periodic background checks (configurable interval)
|
|
12
|
+
* - Exposes JWKS for /.well-known/jwks.json
|
|
13
|
+
* - Signs JWTs with the active key
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
generateKeyPair,
|
|
18
|
+
exportJWK,
|
|
19
|
+
importJWK,
|
|
20
|
+
SignJWT,
|
|
21
|
+
type JWK,
|
|
22
|
+
} from "jose";
|
|
23
|
+
|
|
24
|
+
// ── Types ──
|
|
25
|
+
|
|
26
|
+
export type KeyStatus = "active" | "deprecated" | "revoked";
|
|
27
|
+
|
|
28
|
+
export interface StoredKey {
|
|
29
|
+
kid: string;
|
|
30
|
+
alg: string;
|
|
31
|
+
status: KeyStatus;
|
|
32
|
+
publicJwk: JWK;
|
|
33
|
+
privateJwk: JWK;
|
|
34
|
+
createdAt: Date;
|
|
35
|
+
expiresAt: Date;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface CachedKey extends StoredKey {
|
|
39
|
+
privateKey: CryptoKey;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pluggable store interface for key persistence.
|
|
44
|
+
* Implement this for your database (CockroachDB, Postgres, SQLite, etc.)
|
|
45
|
+
*/
|
|
46
|
+
export interface KeyStore {
|
|
47
|
+
/** Load all non-revoked keys */
|
|
48
|
+
loadKeys(): Promise<StoredKey[]>;
|
|
49
|
+
/** Insert a new key */
|
|
50
|
+
insertKey(key: StoredKey): Promise<void>;
|
|
51
|
+
/** Set all active keys to deprecated */
|
|
52
|
+
deprecateAllActive(): Promise<void>;
|
|
53
|
+
/** Delete expired keys, return count deleted */
|
|
54
|
+
cleanupExpired(): Promise<number>;
|
|
55
|
+
/**
|
|
56
|
+
* Run operations atomically. Implementations with transaction support
|
|
57
|
+
* should wrap the callback in a DB transaction to ensure rotate() is
|
|
58
|
+
* atomic (load + deprecate + insert + cleanup all succeed or all fail).
|
|
59
|
+
* For stores without real tx support, pass a no-op wrapper that runs fn sequentially.
|
|
60
|
+
*/
|
|
61
|
+
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface KeyManager {
|
|
65
|
+
/** Get the JWKS for /.well-known/jwks.json */
|
|
66
|
+
getJwks(): { keys: JWK[] };
|
|
67
|
+
/** Sign a JWT with the active key */
|
|
68
|
+
signJwt(claims: Record<string, unknown>): Promise<string>;
|
|
69
|
+
/** Force a key rotation */
|
|
70
|
+
rotate(): Promise<void>;
|
|
71
|
+
/** Stop the background rotation check */
|
|
72
|
+
stop(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface KeyManagerOptions {
|
|
76
|
+
/** Key store implementation */
|
|
77
|
+
store: KeyStore;
|
|
78
|
+
/** Issuer URL for the iss claim */
|
|
79
|
+
issuer: string;
|
|
80
|
+
/** How often to check if rotation is needed (default: 5 min) */
|
|
81
|
+
checkIntervalMs?: number;
|
|
82
|
+
/** Max age of an active key before rotation (default: 1 hour) */
|
|
83
|
+
rotationThresholdMs?: number;
|
|
84
|
+
/** How long deprecated keys stay in JWKS for verification (default: 2 hours) */
|
|
85
|
+
keyLifetimeMs?: number;
|
|
86
|
+
/** Token TTL in seconds (default: 300 = 5 min) */
|
|
87
|
+
tokenTtlSeconds?: number;
|
|
88
|
+
/** Enable background key rotation (default: true). Set to false on read-only replicas. */
|
|
89
|
+
enableRotation?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Constants ──
|
|
93
|
+
|
|
94
|
+
const FIVE_MINUTES = 5 * 60 * 1000;
|
|
95
|
+
const ONE_HOUR = 60 * 60 * 1000;
|
|
96
|
+
const TWO_HOURS = 2 * ONE_HOUR;
|
|
97
|
+
const ALG = "ES256";
|
|
98
|
+
|
|
99
|
+
// ── Key generation ──
|
|
100
|
+
|
|
101
|
+
function generateKid(): string {
|
|
102
|
+
return `key-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function generateNewKey(keyLifetimeMs: number): Promise<StoredKey> {
|
|
106
|
+
const { privateKey, publicKey } = await generateKeyPair(ALG, { extractable: true });
|
|
107
|
+
const kid = generateKid();
|
|
108
|
+
|
|
109
|
+
const publicJwk = await exportJWK(publicKey);
|
|
110
|
+
publicJwk.kid = kid;
|
|
111
|
+
publicJwk.alg = ALG;
|
|
112
|
+
publicJwk.use = "sig";
|
|
113
|
+
|
|
114
|
+
const privateJwk = await exportJWK(privateKey);
|
|
115
|
+
privateJwk.kid = kid;
|
|
116
|
+
privateJwk.alg = ALG;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
kid,
|
|
120
|
+
alg: ALG,
|
|
121
|
+
status: "active",
|
|
122
|
+
publicJwk,
|
|
123
|
+
privateJwk,
|
|
124
|
+
createdAt: new Date(),
|
|
125
|
+
expiresAt: new Date(Date.now() + keyLifetimeMs),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function toCachedKey(stored: StoredKey): Promise<CachedKey> {
|
|
130
|
+
const privateKey = await importJWK(stored.privateJwk, ALG) as CryptoKey;
|
|
131
|
+
return { ...stored, privateKey };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Key Manager ──
|
|
135
|
+
|
|
136
|
+
export async function createKeyManager(opts: KeyManagerOptions): Promise<KeyManager> {
|
|
137
|
+
const {
|
|
138
|
+
store,
|
|
139
|
+
issuer,
|
|
140
|
+
checkIntervalMs = FIVE_MINUTES,
|
|
141
|
+
rotationThresholdMs = ONE_HOUR,
|
|
142
|
+
keyLifetimeMs = TWO_HOURS,
|
|
143
|
+
tokenTtlSeconds = 300,
|
|
144
|
+
enableRotation = true,
|
|
145
|
+
} = opts;
|
|
146
|
+
|
|
147
|
+
let keys: CachedKey[] = [];
|
|
148
|
+
|
|
149
|
+
/** Generate a new key, deprecate old ones, cleanup expired, refresh cache — all in one transaction */
|
|
150
|
+
async function rotate(): Promise<void> {
|
|
151
|
+
await store.transaction(async () => {
|
|
152
|
+
const newKey = await generateNewKey(keyLifetimeMs);
|
|
153
|
+
await store.deprecateAllActive();
|
|
154
|
+
await store.insertKey(newKey);
|
|
155
|
+
await store.cleanupExpired();
|
|
156
|
+
const updated = await store.loadKeys();
|
|
157
|
+
keys = await Promise.all(updated.map(toCachedKey));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Check if rotation is needed and rotate if so — all within a single transaction */
|
|
162
|
+
async function checkAndRotate(): Promise<void> {
|
|
163
|
+
// Quick check against cache — no DB/store hit if key is fresh
|
|
164
|
+
const cached = keys.find((k) => k.status === "active");
|
|
165
|
+
if (cached) {
|
|
166
|
+
const age = Date.now() - cached.createdAt.getTime();
|
|
167
|
+
if (age < rotationThresholdMs) return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Cache says stale (or empty) — take a lock via transaction to check + rotate atomically
|
|
171
|
+
await store.transaction(async () => {
|
|
172
|
+
const stored = await store.loadKeys();
|
|
173
|
+
const active = stored.find((k) => k.status === "active");
|
|
174
|
+
|
|
175
|
+
if (active) {
|
|
176
|
+
const age = Date.now() - active.createdAt.getTime();
|
|
177
|
+
if (age < rotationThresholdMs) {
|
|
178
|
+
// Another instance already rotated — just update our cache
|
|
179
|
+
keys = await Promise.all(stored.map(toCachedKey));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Still stale (or no active key) — rotate within this tx
|
|
185
|
+
const newKey = await generateNewKey(keyLifetimeMs);
|
|
186
|
+
await store.deprecateAllActive();
|
|
187
|
+
await store.insertKey(newKey);
|
|
188
|
+
await store.cleanupExpired();
|
|
189
|
+
|
|
190
|
+
// Refresh cache inside the tx for a consistent read
|
|
191
|
+
const updated = await store.loadKeys();
|
|
192
|
+
keys = await Promise.all(updated.map(toCachedKey));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Initial load + ensure we have at least one key
|
|
197
|
+
// Initial load from store
|
|
198
|
+
const stored = await store.loadKeys();
|
|
199
|
+
keys = await Promise.all(stored.map(toCachedKey));
|
|
200
|
+
if (!keys.some((k) => k.status === "active")) {
|
|
201
|
+
if (enableRotation) {
|
|
202
|
+
await rotate();
|
|
203
|
+
} else {
|
|
204
|
+
// Read-only mode: generate a key in memory only (no store writes)
|
|
205
|
+
// This ensures signJwt works even without rotation enabled
|
|
206
|
+
const newKey = await generateNewKey(keyLifetimeMs);
|
|
207
|
+
keys.push(await toCachedKey(newKey));
|
|
208
|
+
}
|
|
209
|
+
} else if (enableRotation) {
|
|
210
|
+
await checkAndRotate();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Periodic background check (only if rotation enabled)
|
|
214
|
+
const interval = enableRotation
|
|
215
|
+
? setInterval(async () => {
|
|
216
|
+
try {
|
|
217
|
+
await checkAndRotate();
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error("[key-manager] Check/rotation failed:", err);
|
|
220
|
+
}
|
|
221
|
+
}, checkIntervalMs)
|
|
222
|
+
: null;
|
|
223
|
+
|
|
224
|
+
function getActiveKey(): CachedKey {
|
|
225
|
+
const active = keys.find((k) => k.status === "active");
|
|
226
|
+
if (!active) throw new Error("[key-manager] No active signing key");
|
|
227
|
+
return active;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
getJwks(): { keys: JWK[] } {
|
|
232
|
+
return {
|
|
233
|
+
keys: keys
|
|
234
|
+
.filter((k) => k.status !== "revoked")
|
|
235
|
+
.map((k) => k.publicJwk),
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
async signJwt(claims: Record<string, unknown>): Promise<string> {
|
|
240
|
+
const key = getActiveKey();
|
|
241
|
+
return new SignJWT({ ...claims } as any)
|
|
242
|
+
.setProtectedHeader({ alg: ALG, kid: key.kid })
|
|
243
|
+
.setIssuer(issuer)
|
|
244
|
+
.setIssuedAt()
|
|
245
|
+
.setExpirationTime(`${tokenTtlSeconds}s`)
|
|
246
|
+
.sign(key.privateKey);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async rotate(): Promise<void> {
|
|
250
|
+
await rotate();
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
stop(): void {
|
|
254
|
+
if (interval) clearInterval(interval);
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -100,6 +100,8 @@ export interface AgentServerOptions {
|
|
|
100
100
|
signingKey?: SigningKey;
|
|
101
101
|
/** OAuth identity provider for cross-registry user linking */
|
|
102
102
|
oauthIdentityProvider?: OAuthIdentityProvider;
|
|
103
|
+
/** Key store for managed key rotation (if provided, uses createKeyManager instead of simple key gen) */
|
|
104
|
+
keyStore?: import("./key-manager.js").KeyStore;
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
export interface AgentServer {
|
|
@@ -588,6 +590,14 @@ export function createAgentServer(
|
|
|
588
590
|
// OAuth2 token handler
|
|
589
591
|
// ──────────────────────────────────────────
|
|
590
592
|
|
|
593
|
+
// Resolve public-facing base URL, respecting reverse proxy headers
|
|
594
|
+
const resolveBaseUrl = (r: Request): string => {
|
|
595
|
+
const fwdProto = r.headers.get("x-forwarded-proto");
|
|
596
|
+
const fwdHost = r.headers.get("x-forwarded-host");
|
|
597
|
+
if (fwdProto && fwdHost) return `${fwdProto}://${fwdHost}`;
|
|
598
|
+
return new URL(r.url).origin;
|
|
599
|
+
};
|
|
600
|
+
|
|
591
601
|
async function handleOAuthToken(req: Request): Promise<Response> {
|
|
592
602
|
if (!authConfig) {
|
|
593
603
|
return jsonResponse({ error: "auth_not_configured" }, 404);
|
|
@@ -639,7 +649,7 @@ export function createAgentServer(
|
|
|
639
649
|
const assertionParts = assertion.split(".");
|
|
640
650
|
if (assertionParts.length === 3) {
|
|
641
651
|
const assertionPayload = JSON.parse(atob(assertionParts[1].replace(/-/g, "+").replace(/_/g, "/"))) as any;
|
|
642
|
-
if (assertionPayload.type === "agent-registry" && assertionPayload.iss) {
|
|
652
|
+
if (assertionPayload.type === "agent-registry" && assertionPayload.iss && false /* disabled: causes infinite loop */) {
|
|
643
653
|
// Find or create @remote-registry agent and store the reverse connection
|
|
644
654
|
const rrAgent = registry.get("@remote-registry") ?? registry.get("/agents/@remote-registry");
|
|
645
655
|
if (rrAgent) {
|
|
@@ -675,7 +685,7 @@ export function createAgentServer(
|
|
|
675
685
|
|
|
676
686
|
// User not linked yet — needs OAuth identity linking
|
|
677
687
|
if (exchangeResult.needsAuth) {
|
|
678
|
-
const baseUrl =
|
|
688
|
+
const baseUrl = resolveBaseUrl(req);
|
|
679
689
|
const authorizeUrl = new URL(`${baseUrl}${basePath}/oauth/authorize`);
|
|
680
690
|
authorizeUrl.searchParams.set("token", assertion);
|
|
681
691
|
if (params.redirect_uri) {
|
|
@@ -707,7 +717,7 @@ export function createAgentServer(
|
|
|
707
717
|
},
|
|
708
718
|
sigKey.privateKey,
|
|
709
719
|
sigKey.kid,
|
|
710
|
-
|
|
720
|
+
resolveBaseUrl(req),
|
|
711
721
|
`${authConfig.tokenTtl ?? 3600}s`,
|
|
712
722
|
);
|
|
713
723
|
|
|
@@ -789,6 +799,7 @@ export function createAgentServer(
|
|
|
789
799
|
|
|
790
800
|
async function fetch(req: Request): Promise<Response> {
|
|
791
801
|
try {
|
|
802
|
+
|
|
792
803
|
const url = new URL(req.url);
|
|
793
804
|
const path = url.pathname.replace(basePath, "") || "/";
|
|
794
805
|
|
|
@@ -856,10 +867,14 @@ export function createAgentServer(
|
|
|
856
867
|
return cors ? addCors(res) : res;
|
|
857
868
|
}
|
|
858
869
|
|
|
859
|
-
// Verify the JWT against trusted issuers
|
|
870
|
+
// Verify the JWT against trusted issuers (from store, falling back to config)
|
|
860
871
|
let claims: Record<string, unknown> | null = null;
|
|
861
|
-
const
|
|
862
|
-
|
|
872
|
+
const storeIssuers = authConfig?.store?.listTrustedIssuers
|
|
873
|
+
? await authConfig.store.listTrustedIssuers()
|
|
874
|
+
: [];
|
|
875
|
+
const configIssuerUrls = configTrustedIssuers.map(i => typeof i === "string" ? i : i.issuer);
|
|
876
|
+
const allIssuerUrls = [...new Set([...storeIssuers, ...configIssuerUrls])];
|
|
877
|
+
for (const issuerUrl of allIssuerUrls) {
|
|
863
878
|
try {
|
|
864
879
|
const result = await verifyJwtFromIssuer(token, issuerUrl);
|
|
865
880
|
if (result) {
|
|
@@ -876,7 +891,7 @@ export function createAgentServer(
|
|
|
876
891
|
return cors ? addCors(res) : res;
|
|
877
892
|
}
|
|
878
893
|
|
|
879
|
-
const baseUrl =
|
|
894
|
+
const baseUrl = resolveBaseUrl(req);
|
|
880
895
|
const scope = url.searchParams.get("scope") ?? undefined;
|
|
881
896
|
const res = await oauthIdentityProvider.authorize(req, {
|
|
882
897
|
token,
|
|
@@ -897,7 +912,7 @@ export function createAgentServer(
|
|
|
897
912
|
);
|
|
898
913
|
return cors ? addCors(res) : res;
|
|
899
914
|
}
|
|
900
|
-
const baseUrl =
|
|
915
|
+
const baseUrl = resolveBaseUrl(req);
|
|
901
916
|
const res = await oauthIdentityProvider.callback(req, {
|
|
902
917
|
baseUrl: baseUrl + basePath,
|
|
903
918
|
});
|
|
@@ -921,7 +936,7 @@ export function createAgentServer(
|
|
|
921
936
|
|
|
922
937
|
// ── GET /.well-known/configuration → Server discovery ──
|
|
923
938
|
if (path === "/.well-known/configuration" && req.method === "GET") {
|
|
924
|
-
const baseUrl =
|
|
939
|
+
const baseUrl = resolveBaseUrl(req);
|
|
925
940
|
const res = jsonResponse({
|
|
926
941
|
issuer: baseUrl,
|
|
927
942
|
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
|