@slashfi/agents-sdk 0.11.2 → 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 +17 -1
- package/dist/agent-definitions/auth.d.ts.map +1 -1
- package/dist/agent-definitions/auth.js +123 -4
- package/dist/agent-definitions/auth.js.map +1 -1
- package/dist/agent-definitions/integrations.d.ts +2 -14
- package/dist/agent-definitions/integrations.d.ts.map +1 -1
- package/dist/agent-definitions/integrations.js +64 -19
- package/dist/agent-definitions/integrations.js.map +1 -1
- package/dist/agent-definitions/remote-registry.d.ts +19 -14
- package/dist/agent-definitions/remote-registry.d.ts.map +1 -1
- package/dist/agent-definitions/remote-registry.js +219 -381
- package/dist/agent-definitions/remote-registry.js.map +1 -1
- package/dist/agent-definitions/users.d.ts.map +1 -1
- package/dist/agent-definitions/users.js +29 -1
- package/dist/agent-definitions/users.js.map +1 -1
- package/dist/define.d.ts +6 -4
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +82 -3
- package/dist/define.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/jwt.js +1 -1
- package/dist/jwt.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 +39 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +228 -52
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +53 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/agent-definitions/auth.ts +165 -6
- package/src/agent-definitions/integrations.ts +59 -28
- package/src/agent-definitions/remote-registry.ts +219 -513
- package/src/agent-definitions/users.ts +35 -1
- package/src/define.ts +98 -6
- package/src/index.ts +3 -1
- package/src/jwt.ts +1 -1
- package/src/key-manager.test.ts +273 -0
- package/src/key-manager.ts +257 -0
- package/src/server.test.ts +284 -0
- package/src/server.ts +334 -60
- package/src/types.ts +44 -1
|
@@ -250,10 +250,30 @@ export function createUsersAgent(options: UsersAgentOptions): AgentDefinition {
|
|
|
250
250
|
name: { type: "string", description: "Display name" },
|
|
251
251
|
avatarUrl: { type: "string", description: "Avatar URL" },
|
|
252
252
|
metadata: { type: "object", description: "Additional metadata" },
|
|
253
|
+
externalRef: {
|
|
254
|
+
type: "object",
|
|
255
|
+
description: "Link to a user on a remote system. Creates identity automatically.",
|
|
256
|
+
properties: {
|
|
257
|
+
issuer: { type: "string", description: "Issuer URL of the remote system" },
|
|
258
|
+
userId: { type: "string", description: "User ID on the remote system" },
|
|
259
|
+
},
|
|
260
|
+
},
|
|
253
261
|
},
|
|
254
262
|
required: ["tenantId"],
|
|
255
263
|
},
|
|
256
264
|
execute: async (input: any, __ctx: ToolContext) => {
|
|
265
|
+
// If externalRef provided, check if identity already exists
|
|
266
|
+
if (input.externalRef) {
|
|
267
|
+
const existing = await store.findIdentityByProviderUserId(
|
|
268
|
+
input.externalRef.issuer,
|
|
269
|
+
input.externalRef.userId,
|
|
270
|
+
);
|
|
271
|
+
if (existing) {
|
|
272
|
+
const user = await store.getUser(existing.userId);
|
|
273
|
+
return { success: true, user, identity: existing, alreadyLinked: true };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
257
277
|
const user = await store.createUser({
|
|
258
278
|
id: input.id ?? generateId("user_"),
|
|
259
279
|
tenantId: input.tenantId,
|
|
@@ -262,7 +282,21 @@ export function createUsersAgent(options: UsersAgentOptions): AgentDefinition {
|
|
|
262
282
|
avatarUrl: input.avatarUrl,
|
|
263
283
|
metadata: input.metadata,
|
|
264
284
|
});
|
|
265
|
-
|
|
285
|
+
|
|
286
|
+
// Auto-create identity link if externalRef provided
|
|
287
|
+
let identity: UserIdentity | undefined;
|
|
288
|
+
if (input.externalRef) {
|
|
289
|
+
identity = await store.createIdentity({
|
|
290
|
+
id: generateId("uid_"),
|
|
291
|
+
userId: user.id,
|
|
292
|
+
provider: input.externalRef.issuer,
|
|
293
|
+
providerUserId: input.externalRef.userId,
|
|
294
|
+
email: input.email,
|
|
295
|
+
name: input.name,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { success: true, user, identity };
|
|
266
300
|
},
|
|
267
301
|
});
|
|
268
302
|
|
package/src/define.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
|
+
IntegrationHooks,
|
|
8
9
|
AgentConfig,
|
|
9
10
|
AgentDefinition,
|
|
10
11
|
AgentRuntime,
|
|
11
|
-
IntegrationMethods,
|
|
12
12
|
JsonSchema,
|
|
13
13
|
ToolContext,
|
|
14
14
|
ToolDefinition,
|
|
@@ -115,10 +115,18 @@ export interface DefineAgentOptions<
|
|
|
115
115
|
allowedCallers?: string[];
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
+
* Integration hooks. When provided, defineAgent auto-generates
|
|
119
|
+
* setup_integration, connect_integration, etc. as tools.
|
|
120
|
+
*/
|
|
121
|
+
integration?: IntegrationHooks;
|
|
122
|
+
|
|
123
|
+
/** Lazy loader for event listeners (e.g. entrypoint.ts) */
|
|
124
|
+
loadListeners?: () => Promise<unknown>;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @deprecated Use `integration` instead.
|
|
118
128
|
* Integration method callbacks.
|
|
119
|
-
* Implement these when this agent acts as an integration.
|
|
120
129
|
*/
|
|
121
|
-
integrationMethods?: IntegrationMethods;
|
|
122
130
|
}
|
|
123
131
|
|
|
124
132
|
/**
|
|
@@ -148,14 +156,98 @@ export interface DefineAgentOptions<
|
|
|
148
156
|
export function defineAgent<TContext extends ToolContext = ToolContext>(
|
|
149
157
|
options: DefineAgentOptions<TContext>,
|
|
150
158
|
): AgentDefinition<TContext> {
|
|
159
|
+
const tools = [...(options.tools ?? [])];
|
|
160
|
+
let config = options.config;
|
|
161
|
+
|
|
162
|
+
// Auto-generate integration tools from hooks
|
|
163
|
+
if (options.integration) {
|
|
164
|
+
const h = options.integration;
|
|
165
|
+
|
|
166
|
+
// Set config.integration metadata if not already set
|
|
167
|
+
if (!config?.integration) {
|
|
168
|
+
config = {
|
|
169
|
+
...config,
|
|
170
|
+
integration: {
|
|
171
|
+
provider: h.provider,
|
|
172
|
+
displayName: h.displayName,
|
|
173
|
+
icon: h.icon,
|
|
174
|
+
category: h.category,
|
|
175
|
+
description: h.description,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (h.setup) {
|
|
181
|
+
const fn = h.setup;
|
|
182
|
+
tools.push(defineTool({
|
|
183
|
+
name: "setup_integration",
|
|
184
|
+
description: `Set up ${h.displayName} integration.`,
|
|
185
|
+
visibility: "public" as const,
|
|
186
|
+
inputSchema: { type: "object" as const, properties: { url: { type: "string" }, name: { type: "string" }, config: { type: "object" } } },
|
|
187
|
+
execute: (input: any, ctx: any) => fn(input, ctx),
|
|
188
|
+
}) as any);
|
|
189
|
+
}
|
|
190
|
+
if (h.connect) {
|
|
191
|
+
const fn = h.connect;
|
|
192
|
+
tools.push(defineTool({
|
|
193
|
+
name: "connect_integration",
|
|
194
|
+
description: `Connect a user to ${h.displayName}.`,
|
|
195
|
+
visibility: "public" as const,
|
|
196
|
+
inputSchema: { type: "object" as const, properties: { registryId: { type: "string" }, oidcUserId: { type: "string" }, redirectUri: { type: "string" } }, required: ["registryId"] as const },
|
|
197
|
+
execute: (input: any, ctx: any) => fn(input, ctx),
|
|
198
|
+
}) as any);
|
|
199
|
+
}
|
|
200
|
+
if (h.discover) {
|
|
201
|
+
const fn = h.discover;
|
|
202
|
+
tools.push(defineTool({
|
|
203
|
+
name: "discover_integrations",
|
|
204
|
+
description: `Discover available ${h.displayName} instances.`,
|
|
205
|
+
visibility: "public" as const,
|
|
206
|
+
inputSchema: { type: "object" as const, properties: { url: { type: "string" } } },
|
|
207
|
+
execute: (input: any, ctx: any) => fn(input, ctx),
|
|
208
|
+
}) as any);
|
|
209
|
+
}
|
|
210
|
+
if (h.list) {
|
|
211
|
+
const fn = h.list;
|
|
212
|
+
tools.push(defineTool({
|
|
213
|
+
name: "list_integrations",
|
|
214
|
+
description: `List connected ${h.displayName} instances.`,
|
|
215
|
+
visibility: "public" as const,
|
|
216
|
+
inputSchema: { type: "object" as const, properties: {} },
|
|
217
|
+
execute: (input: any, ctx: any) => fn(input, ctx),
|
|
218
|
+
}) as any);
|
|
219
|
+
}
|
|
220
|
+
if (h.get) {
|
|
221
|
+
const fn = h.get;
|
|
222
|
+
tools.push(defineTool({
|
|
223
|
+
name: "get_integration",
|
|
224
|
+
description: `Get details of a ${h.displayName} instance.`,
|
|
225
|
+
visibility: "public" as const,
|
|
226
|
+
inputSchema: { type: "object" as const, properties: { registryId: { type: "string" } }, required: ["registryId"] as const },
|
|
227
|
+
execute: (input: any, ctx: any) => fn(input, ctx),
|
|
228
|
+
}) as any);
|
|
229
|
+
}
|
|
230
|
+
if (h.update) {
|
|
231
|
+
const fn = h.update;
|
|
232
|
+
tools.push(defineTool({
|
|
233
|
+
name: "update_integration",
|
|
234
|
+
description: `Update a ${h.displayName} instance.`,
|
|
235
|
+
visibility: "public" as const,
|
|
236
|
+
inputSchema: { type: "object" as const, properties: { registryId: { type: "string" }, name: { type: "string" }, url: { type: "string" } }, required: ["registryId"] as const },
|
|
237
|
+
execute: (input: any, ctx: any) => fn(input, ctx),
|
|
238
|
+
}) as any);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
151
243
|
return {
|
|
152
244
|
path: options.path,
|
|
153
245
|
entrypoint: options.entrypoint,
|
|
154
|
-
config
|
|
155
|
-
tools
|
|
246
|
+
config,
|
|
247
|
+
tools,
|
|
156
248
|
runtime: options.runtime,
|
|
157
249
|
visibility: options.visibility,
|
|
158
250
|
allowedCallers: options.allowedCallers,
|
|
159
|
-
|
|
251
|
+
loadListeners: options.loadListeners,
|
|
160
252
|
};
|
|
161
253
|
}
|
package/src/index.ts
CHANGED
|
@@ -85,6 +85,7 @@ export type {
|
|
|
85
85
|
IntegrationMethods,
|
|
86
86
|
IntegrationMethodResult,
|
|
87
87
|
IntegrationMethodContext,
|
|
88
|
+
IntegrationHooks,
|
|
88
89
|
Visibility,
|
|
89
90
|
} from "./types.js";
|
|
90
91
|
|
|
@@ -98,7 +99,7 @@ export type { AgentRegistry, AgentRegistryOptions } from "./registry.js";
|
|
|
98
99
|
|
|
99
100
|
// Server
|
|
100
101
|
export { createAgentServer, detectAuth, resolveAuth, canSeeAgent } from "./server.js";
|
|
101
|
-
export type { AgentServer, AgentServerOptions, AuthConfig, ResolvedAuth, TrustedIssuer } from "./server.js";
|
|
102
|
+
export type { AgentServer, AgentServerOptions, AuthConfig, OAuthIdentityProvider, ResolvedAuth, TrustedIssuer } from "./server.js";
|
|
102
103
|
|
|
103
104
|
// Secret Collection
|
|
104
105
|
export {
|
|
@@ -193,3 +194,4 @@ export type {
|
|
|
193
194
|
export * from "./integrations-store.js";
|
|
194
195
|
export * from "./integration-interface.js";
|
|
195
196
|
export type { ContextFactory } from "./registry.js";
|
|
197
|
+
export { createKeyManager, type KeyManager, type KeyStore, type KeyManagerOptions, type StoredKey, type KeyStatus } from "./key-manager.js";
|
package/src/jwt.ts
CHANGED
|
@@ -80,7 +80,7 @@ export interface ExportedKeyPair {
|
|
|
80
80
|
* Generate a new ES256 signing key pair.
|
|
81
81
|
*/
|
|
82
82
|
export async function generateSigningKey(kid?: string): Promise<SigningKey> {
|
|
83
|
-
const { privateKey, publicKey } = await generateKeyPair("ES256");
|
|
83
|
+
const { privateKey, publicKey } = await generateKeyPair("ES256", { extractable: true });
|
|
84
84
|
return {
|
|
85
85
|
kid: kid ?? `key-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
86
86
|
privateKey,
|
|
@@ -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
|
+
});
|