@slashfi/agents-sdk 0.12.0 → 0.14.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 +59 -8
- 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 +43 -27
- 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 +57 -9
- 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 +44 -28
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "bun:test";
|
|
2
|
+
import { createKeyManager, type KeyStore, type StoredKey, type KeyManager } from "./key-manager";
|
|
3
|
+
import { jwtVerify, createLocalJWKSet } from "jose";
|
|
4
|
+
|
|
5
|
+
// In-memory KeyStore for testing (no DB needed)
|
|
6
|
+
function createMemoryKeyStore(): KeyStore & { keys: StoredKey[] } {
|
|
7
|
+
const keys: StoredKey[] = [];
|
|
8
|
+
return {
|
|
9
|
+
keys,
|
|
10
|
+
async loadKeys() {
|
|
11
|
+
return keys.filter((k) => k.status !== "revoked");
|
|
12
|
+
},
|
|
13
|
+
async insertKey(key: StoredKey) {
|
|
14
|
+
keys.push(key);
|
|
15
|
+
},
|
|
16
|
+
async deprecateAllActive() {
|
|
17
|
+
for (const k of keys) {
|
|
18
|
+
if (k.status === "active") k.status = "deprecated";
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
async cleanupExpired() {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const before = keys.length;
|
|
24
|
+
const remaining = keys.filter((k) => k.expiresAt.getTime() > now);
|
|
25
|
+
keys.length = 0;
|
|
26
|
+
keys.push(...remaining);
|
|
27
|
+
return before - remaining.length;
|
|
28
|
+
},
|
|
29
|
+
// Transaction support: snapshot + rollback on error
|
|
30
|
+
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
31
|
+
const snapshot = keys.map((k) => ({ ...k }));
|
|
32
|
+
try {
|
|
33
|
+
return await fn();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// Rollback
|
|
36
|
+
keys.length = 0;
|
|
37
|
+
keys.push(...snapshot);
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("KeyManager", () => {
|
|
45
|
+
let km: KeyManager;
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
km?.stop();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("creates initial key on startup", async () => {
|
|
52
|
+
const store = createMemoryKeyStore();
|
|
53
|
+
km = await createKeyManager({
|
|
54
|
+
store,
|
|
55
|
+
issuer: "http://test:3000",
|
|
56
|
+
checkIntervalMs: 60_000,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const jwks = km.getJwks();
|
|
60
|
+
expect(jwks.keys).toHaveLength(1);
|
|
61
|
+
expect(jwks.keys[0].alg).toBe("ES256");
|
|
62
|
+
expect(jwks.keys[0].kid).toMatch(/^key-/);
|
|
63
|
+
// No private key exposed
|
|
64
|
+
expect(jwks.keys[0].d).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("signs a valid JWT", async () => {
|
|
68
|
+
const store = createMemoryKeyStore();
|
|
69
|
+
km = await createKeyManager({
|
|
70
|
+
store,
|
|
71
|
+
issuer: "http://test:3000",
|
|
72
|
+
checkIntervalMs: 60_000,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const token = await km.signJwt({ sub: "test-service" });
|
|
76
|
+
const parts = token.split(".");
|
|
77
|
+
expect(parts).toHaveLength(3);
|
|
78
|
+
|
|
79
|
+
const payload = JSON.parse(
|
|
80
|
+
Buffer.from(parts[1], "base64url").toString()
|
|
81
|
+
);
|
|
82
|
+
expect(payload.sub).toBe("test-service");
|
|
83
|
+
expect(payload.iss).toBe("http://test:3000");
|
|
84
|
+
expect(payload.exp - payload.iat).toBe(300); // default 5 min TTL
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("token verifies against JWKS", async () => {
|
|
88
|
+
const store = createMemoryKeyStore();
|
|
89
|
+
km = await createKeyManager({
|
|
90
|
+
store,
|
|
91
|
+
issuer: "http://test:3000",
|
|
92
|
+
checkIntervalMs: 60_000,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const token = await km.signJwt({ sub: "verify-me" });
|
|
96
|
+
const jwks = km.getJwks();
|
|
97
|
+
const JWKS = createLocalJWKSet(jwks);
|
|
98
|
+
const { payload } = await jwtVerify(token, JWKS);
|
|
99
|
+
expect(payload.sub).toBe("verify-me");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("custom token TTL", async () => {
|
|
103
|
+
const store = createMemoryKeyStore();
|
|
104
|
+
km = await createKeyManager({
|
|
105
|
+
store,
|
|
106
|
+
issuer: "http://test:3000",
|
|
107
|
+
tokenTtlSeconds: 60,
|
|
108
|
+
checkIntervalMs: 60_000,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const token = await km.signJwt({ sub: "short-lived" });
|
|
112
|
+
const payload = JSON.parse(
|
|
113
|
+
Buffer.from(token.split(".")[1], "base64url").toString()
|
|
114
|
+
);
|
|
115
|
+
expect(payload.exp - payload.iat).toBe(60);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---- Rotation tests ----
|
|
119
|
+
|
|
120
|
+
test("rotation: creates new key when threshold exceeded", async () => {
|
|
121
|
+
const store = createMemoryKeyStore();
|
|
122
|
+
km = await createKeyManager({
|
|
123
|
+
store,
|
|
124
|
+
issuer: "http://test:3000",
|
|
125
|
+
rotationThresholdMs: 0, // immediate rotation
|
|
126
|
+
checkIntervalMs: 60_000,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const initialKid = km.getJwks().keys.find(
|
|
130
|
+
(k) => store.keys.find((sk) => sk.kid === k.kid)?.status === "active"
|
|
131
|
+
)?.kid;
|
|
132
|
+
|
|
133
|
+
// Force rotation
|
|
134
|
+
await km.rotate();
|
|
135
|
+
|
|
136
|
+
const activeKeys = store.keys.filter((k) => k.status === "active");
|
|
137
|
+
expect(activeKeys).toHaveLength(1);
|
|
138
|
+
expect(activeKeys[0].kid).not.toBe(initialKid);
|
|
139
|
+
|
|
140
|
+
const deprecatedKeys = store.keys.filter((k) => k.status === "deprecated");
|
|
141
|
+
expect(deprecatedKeys.length).toBeGreaterThanOrEqual(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("rotation: deprecated keys still in JWKS for verification", async () => {
|
|
145
|
+
const store = createMemoryKeyStore();
|
|
146
|
+
km = await createKeyManager({
|
|
147
|
+
store,
|
|
148
|
+
issuer: "http://test:3000",
|
|
149
|
+
rotationThresholdMs: 0,
|
|
150
|
+
checkIntervalMs: 60_000,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await km.rotate();
|
|
154
|
+
|
|
155
|
+
const jwksKids = km.getJwks().keys.map((k) => k.kid);
|
|
156
|
+
const nonRevoked = store.keys.filter((k) => k.status !== "revoked");
|
|
157
|
+
for (const key of nonRevoked) {
|
|
158
|
+
expect(jwksKids).toContain(key.kid);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("rotation: pre-rotation tokens still verify", async () => {
|
|
163
|
+
const store = createMemoryKeyStore();
|
|
164
|
+
km = await createKeyManager({
|
|
165
|
+
store,
|
|
166
|
+
issuer: "http://test:3000",
|
|
167
|
+
rotationThresholdMs: 0,
|
|
168
|
+
checkIntervalMs: 60_000,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const preRotationToken = await km.signJwt({ sub: "pre-rotate" });
|
|
172
|
+
await km.rotate();
|
|
173
|
+
|
|
174
|
+
// Pre-rotation token should still verify (deprecated key still in JWKS)
|
|
175
|
+
const jwks = km.getJwks();
|
|
176
|
+
const JWKS = createLocalJWKSet(jwks);
|
|
177
|
+
const { payload } = await jwtVerify(preRotationToken, JWKS);
|
|
178
|
+
expect(payload.sub).toBe("pre-rotate");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("rotation: exactly 1 active key", async () => {
|
|
182
|
+
const store = createMemoryKeyStore();
|
|
183
|
+
km = await createKeyManager({
|
|
184
|
+
store,
|
|
185
|
+
issuer: "http://test:3000",
|
|
186
|
+
rotationThresholdMs: 0,
|
|
187
|
+
checkIntervalMs: 60_000,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await km.rotate();
|
|
191
|
+
await km.rotate();
|
|
192
|
+
|
|
193
|
+
const activeKeys = store.keys.filter((k) => k.status === "active");
|
|
194
|
+
expect(activeKeys).toHaveLength(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("rotation: tokens fail verification after key expires and is cleaned up", async () => {
|
|
198
|
+
const store = createMemoryKeyStore();
|
|
199
|
+
km = await createKeyManager({
|
|
200
|
+
store,
|
|
201
|
+
issuer: "http://test:3000",
|
|
202
|
+
rotationThresholdMs: 0,
|
|
203
|
+
keyLifetimeMs: 1, // 1ms — keys expire almost immediately
|
|
204
|
+
checkIntervalMs: 60_000,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const token = await km.signJwt({ sub: "will-expire" });
|
|
208
|
+
|
|
209
|
+
// Token should verify now (key is active)
|
|
210
|
+
const JWKS = createLocalJWKSet(km.getJwks());
|
|
211
|
+
const { payload } = await jwtVerify(token, JWKS);
|
|
212
|
+
expect(payload.sub).toBe("will-expire");
|
|
213
|
+
|
|
214
|
+
// Wait for key to expire, then rotate (which cleans up expired keys)
|
|
215
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
216
|
+
await km.rotate();
|
|
217
|
+
|
|
218
|
+
// Old key should be gone from JWKS — verification should fail
|
|
219
|
+
const jwksAfter = km.getJwks();
|
|
220
|
+
const JWKS2 = createLocalJWKSet(jwksAfter);
|
|
221
|
+
let failed = false;
|
|
222
|
+
try {
|
|
223
|
+
await jwtVerify(token, JWKS2);
|
|
224
|
+
} catch {
|
|
225
|
+
failed = true;
|
|
226
|
+
}
|
|
227
|
+
expect(failed).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ---- Transaction tests ----
|
|
231
|
+
|
|
232
|
+
test("transaction: rotate is atomic (rollback on failure)", async () => {
|
|
233
|
+
const store = createMemoryKeyStore();
|
|
234
|
+
km = await createKeyManager({
|
|
235
|
+
store,
|
|
236
|
+
issuer: "http://test:3000",
|
|
237
|
+
checkIntervalMs: 60_000,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const initialKid = store.keys[0].kid;
|
|
241
|
+
|
|
242
|
+
// Monkey-patch insertKey to fail mid-transaction
|
|
243
|
+
const origInsert = store.insertKey;
|
|
244
|
+
store.insertKey = async () => { throw new Error("simulated failure"); };
|
|
245
|
+
|
|
246
|
+
// Rotation should fail, but state should be rolled back
|
|
247
|
+
try { await km.rotate(); } catch {}
|
|
248
|
+
|
|
249
|
+
// Original key should still be active (not deprecated)
|
|
250
|
+
const active = store.keys.filter((k) => k.status === "active");
|
|
251
|
+
expect(active).toHaveLength(1);
|
|
252
|
+
expect(active[0].kid).toBe(initialKid);
|
|
253
|
+
|
|
254
|
+
// Restore
|
|
255
|
+
store.insertKey = origInsert;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ---- enableRotation option ----
|
|
259
|
+
|
|
260
|
+
test("enableRotation: false skips background interval", async () => {
|
|
261
|
+
const store = createMemoryKeyStore();
|
|
262
|
+
km = await createKeyManager({
|
|
263
|
+
store,
|
|
264
|
+
issuer: "http://test:3000",
|
|
265
|
+
enableRotation: false,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Should still have an initial key
|
|
269
|
+
expect(km.getJwks().keys).toHaveLength(1);
|
|
270
|
+
// But stop() should be a no-op (no interval to clear)
|
|
271
|
+
km.stop();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -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);
|
|
@@ -638,27 +648,26 @@ export function createAgentServer(
|
|
|
638
648
|
try {
|
|
639
649
|
const assertionParts = assertion.split(".");
|
|
640
650
|
if (assertionParts.length === 3) {
|
|
641
|
-
const assertionPayload = JSON.parse(
|
|
651
|
+
const assertionPayload = JSON.parse(Buffer.from(assertionParts[1], "base64url").toString()) as any;
|
|
642
652
|
if (assertionPayload.type === "agent-registry" && assertionPayload.iss) {
|
|
643
|
-
//
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
}
|
|
653
|
+
// Use add_connection (direct store) instead of setup_integration (which would cause infinite loop)
|
|
654
|
+
const addResult = await registry.call({
|
|
655
|
+
action: "execute_tool",
|
|
656
|
+
path: "@remote-registry",
|
|
657
|
+
tool: "add_connection",
|
|
658
|
+
params: {
|
|
659
|
+
id: assertionPayload.name ?? "remote-registry",
|
|
660
|
+
name: assertionPayload.name ?? "remote-registry",
|
|
661
|
+
url: assertionPayload.iss,
|
|
662
|
+
remoteTenantId: assertionPayload.tenantId ?? "default",
|
|
663
|
+
},
|
|
664
|
+
callerId: "system",
|
|
665
|
+
callerType: "system",
|
|
666
|
+
});
|
|
667
|
+
if (addResult.success) {
|
|
668
|
+
console.error(`[jwt_exchange] Reverse connection stored for ${assertionPayload.iss}`);
|
|
660
669
|
} else {
|
|
661
|
-
console.error(
|
|
670
|
+
console.error(`[jwt_exchange] Reverse connection failed:`, (addResult as any).error);
|
|
662
671
|
}
|
|
663
672
|
}
|
|
664
673
|
}
|
|
@@ -675,7 +684,7 @@ export function createAgentServer(
|
|
|
675
684
|
|
|
676
685
|
// User not linked yet — needs OAuth identity linking
|
|
677
686
|
if (exchangeResult.needsAuth) {
|
|
678
|
-
const baseUrl =
|
|
687
|
+
const baseUrl = resolveBaseUrl(req);
|
|
679
688
|
const authorizeUrl = new URL(`${baseUrl}${basePath}/oauth/authorize`);
|
|
680
689
|
authorizeUrl.searchParams.set("token", assertion);
|
|
681
690
|
if (params.redirect_uri) {
|
|
@@ -707,7 +716,7 @@ export function createAgentServer(
|
|
|
707
716
|
},
|
|
708
717
|
sigKey.privateKey,
|
|
709
718
|
sigKey.kid,
|
|
710
|
-
|
|
719
|
+
resolveBaseUrl(req),
|
|
711
720
|
`${authConfig.tokenTtl ?? 3600}s`,
|
|
712
721
|
);
|
|
713
722
|
|
|
@@ -789,6 +798,7 @@ export function createAgentServer(
|
|
|
789
798
|
|
|
790
799
|
async function fetch(req: Request): Promise<Response> {
|
|
791
800
|
try {
|
|
801
|
+
|
|
792
802
|
const url = new URL(req.url);
|
|
793
803
|
const path = url.pathname.replace(basePath, "") || "/";
|
|
794
804
|
|
|
@@ -856,17 +866,23 @@ export function createAgentServer(
|
|
|
856
866
|
return cors ? addCors(res) : res;
|
|
857
867
|
}
|
|
858
868
|
|
|
859
|
-
// Verify the JWT against trusted issuers
|
|
869
|
+
// Verify the JWT against trusted issuers (from store, falling back to config)
|
|
860
870
|
let claims: Record<string, unknown> | null = null;
|
|
861
|
-
const
|
|
862
|
-
|
|
871
|
+
const storeIssuers = authConfig?.store?.listTrustedIssuers
|
|
872
|
+
? await authConfig.store.listTrustedIssuers()
|
|
873
|
+
: [];
|
|
874
|
+
const configIssuerUrls = configTrustedIssuers.map(i => typeof i === "string" ? i : i.issuer);
|
|
875
|
+
const allIssuerUrls = [...new Set([...storeIssuers, ...configIssuerUrls])];
|
|
876
|
+
console.log("[oauth/authorize] storeIssuers:", storeIssuers.length, "configIssuers:", configIssuerUrls.length, "total:", allIssuerUrls.length, "urls:", allIssuerUrls);
|
|
877
|
+
for (const issuerUrl of allIssuerUrls) {
|
|
863
878
|
try {
|
|
864
879
|
const result = await verifyJwtFromIssuer(token, issuerUrl);
|
|
880
|
+
console.log("[oauth/authorize] verify", issuerUrl, "->", result ? "OK" : "null");
|
|
865
881
|
if (result) {
|
|
866
882
|
claims = result as unknown as Record<string, unknown>;
|
|
867
883
|
break;
|
|
868
884
|
}
|
|
869
|
-
} catch {
|
|
885
|
+
} catch (e: any) { console.log("[oauth/authorize] verify", issuerUrl, "-> ERROR:", e.message); }
|
|
870
886
|
}
|
|
871
887
|
if (!claims) {
|
|
872
888
|
const res = jsonResponse(
|
|
@@ -876,7 +892,7 @@ export function createAgentServer(
|
|
|
876
892
|
return cors ? addCors(res) : res;
|
|
877
893
|
}
|
|
878
894
|
|
|
879
|
-
const baseUrl =
|
|
895
|
+
const baseUrl = resolveBaseUrl(req);
|
|
880
896
|
const scope = url.searchParams.get("scope") ?? undefined;
|
|
881
897
|
const res = await oauthIdentityProvider.authorize(req, {
|
|
882
898
|
token,
|
|
@@ -897,7 +913,7 @@ export function createAgentServer(
|
|
|
897
913
|
);
|
|
898
914
|
return cors ? addCors(res) : res;
|
|
899
915
|
}
|
|
900
|
-
const baseUrl =
|
|
916
|
+
const baseUrl = resolveBaseUrl(req);
|
|
901
917
|
const res = await oauthIdentityProvider.callback(req, {
|
|
902
918
|
baseUrl: baseUrl + basePath,
|
|
903
919
|
});
|
|
@@ -921,7 +937,7 @@ export function createAgentServer(
|
|
|
921
937
|
|
|
922
938
|
// ── GET /.well-known/configuration → Server discovery ──
|
|
923
939
|
if (path === "/.well-known/configuration" && req.method === "GET") {
|
|
924
|
-
const baseUrl =
|
|
940
|
+
const baseUrl = resolveBaseUrl(req);
|
|
925
941
|
const res = jsonResponse({
|
|
926
942
|
issuer: baseUrl,
|
|
927
943
|
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
|