@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
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { defineAgent, defineTool } from "../define.js";
|
|
28
|
-
import { signJwt, generateSigningKey, exportSigningKey, type ExportedKeyPair } from "../jwt.js";
|
|
28
|
+
import { signJwt, generateSigningKey, exportSigningKey, verifyJwtFromIssuer, type ExportedKeyPair } from "../jwt.js";
|
|
29
29
|
import type { AgentDefinition, ToolContext, ToolDefinition } from "../types.js";
|
|
30
30
|
|
|
31
31
|
// ============================================
|
|
@@ -173,6 +173,30 @@ export interface AuthStore {
|
|
|
173
173
|
userId: string;
|
|
174
174
|
clientId: string;
|
|
175
175
|
} | null>;
|
|
176
|
+
|
|
177
|
+
// --- Tenant Identity ---
|
|
178
|
+
|
|
179
|
+
/** Store a tenant identity mapping (foreign issuer + ID -> local tenant). */
|
|
180
|
+
storeTenantIdentity?(tenantId: string, provider: string, providerTenantId: string): Promise<void>;
|
|
181
|
+
|
|
182
|
+
/** Resolve a local tenant ID from a foreign identity. */
|
|
183
|
+
resolveTenantByIdentity?(provider: string, providerTenantId: string): Promise<string | null>;
|
|
184
|
+
|
|
185
|
+
// --- User Identity ---
|
|
186
|
+
|
|
187
|
+
/** Store a user identity mapping (foreign issuer + ID -> local user). */
|
|
188
|
+
storeUserIdentity?(userId: string, provider: string, providerUserId: string): Promise<void>;
|
|
189
|
+
|
|
190
|
+
/** Resolve a local user ID from a foreign identity. */
|
|
191
|
+
resolveUserByIdentity?(provider: string, providerUserId: string): Promise<string | null>;
|
|
192
|
+
|
|
193
|
+
// --- Transaction ---
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run operations atomically. The store scopes its own methods to the
|
|
197
|
+
* transaction context. For stores without real tx support, run fn sequentially.
|
|
198
|
+
*/
|
|
199
|
+
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
176
200
|
}
|
|
177
201
|
|
|
178
202
|
// ============================================
|
|
@@ -218,6 +242,8 @@ export function createMemoryAuthStore(): AuthStore {
|
|
|
218
242
|
const tokens = new Map<string, AuthToken>();
|
|
219
243
|
const signingKeys = new Map<string, ExportedKeyPair>();
|
|
220
244
|
const trustedIssuers = new Set<string>();
|
|
245
|
+
const tenantIdentities = new Map<string, string>(); // "provider:providerTenantId" -> tenantId
|
|
246
|
+
const userIdentities = new Map<string, string>(); // "provider:providerUserId" -> userId
|
|
221
247
|
|
|
222
248
|
return {
|
|
223
249
|
async createTenant(name, _externalRef) {
|
|
@@ -346,6 +372,27 @@ export function createMemoryAuthStore(): AuthStore {
|
|
|
346
372
|
async listTrustedIssuers() {
|
|
347
373
|
return Array.from(trustedIssuers);
|
|
348
374
|
},
|
|
375
|
+
|
|
376
|
+
async storeTenantIdentity(tenantId, provider, providerTenantId) {
|
|
377
|
+
tenantIdentities.set(`${provider}:${providerTenantId}`, tenantId);
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
async resolveTenantByIdentity(provider, providerTenantId) {
|
|
381
|
+
return tenantIdentities.get(`${provider}:${providerTenantId}`) ?? null;
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
async storeUserIdentity(userId, provider, providerUserId) {
|
|
385
|
+
userIdentities.set(`${provider}:${providerUserId}`, userId);
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
async resolveUserByIdentity(provider, providerUserId) {
|
|
389
|
+
return userIdentities.get(`${provider}:${providerUserId}`) ?? null;
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
393
|
+
// In-memory: just run sequentially (single-threaded, no concurrency issues)
|
|
394
|
+
return fn();
|
|
395
|
+
},
|
|
349
396
|
};
|
|
350
397
|
}
|
|
351
398
|
|
|
@@ -783,28 +830,88 @@ export function createAuthAgent(
|
|
|
783
830
|
type: "string" as const,
|
|
784
831
|
description: "JWT signed by a trusted issuer",
|
|
785
832
|
},
|
|
786
|
-
|
|
787
|
-
type: "string" as const,
|
|
788
|
-
description: "Base URL for the OAuth connect flow (returned in needsAuth response)",
|
|
789
|
-
},
|
|
833
|
+
|
|
790
834
|
},
|
|
791
835
|
required: ["token"],
|
|
792
836
|
},
|
|
793
837
|
execute: async (
|
|
794
|
-
|
|
838
|
+
input: { token: string },
|
|
795
839
|
) => {
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
//
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
}
|
|
840
|
+
if (!store.resolveTenantByIdentity || !store.resolveUserByIdentity) {
|
|
841
|
+
return {
|
|
842
|
+
error: "exchange_token requires a store with identity resolution support",
|
|
843
|
+
hint: "Implement storeTenantIdentity/resolveUserByIdentity on your AuthStore",
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// 1. Decode JWT to read iss claim (no verification yet)
|
|
848
|
+
const parts = input.token.split(".");
|
|
849
|
+
if (parts.length !== 3) {
|
|
850
|
+
return { success: false, error: "Invalid JWT format" };
|
|
851
|
+
}
|
|
852
|
+
let decoded: any;
|
|
853
|
+
try {
|
|
854
|
+
decoded = JSON.parse(Buffer.from(parts[1], "base64url").toString());
|
|
855
|
+
} catch {
|
|
856
|
+
return { success: false, error: "Failed to decode JWT payload" };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const issuer = decoded.iss;
|
|
860
|
+
const sub = decoded.sub;
|
|
861
|
+
const foreignTenantId = decoded.tenantId;
|
|
862
|
+
if (!issuer || !sub) {
|
|
863
|
+
return { success: false, error: "JWT missing iss or sub claims" };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// 2. Match issuer against trusted issuers
|
|
867
|
+
const trustedIssuers = store.listTrustedIssuers ? await store.listTrustedIssuers() : [];
|
|
868
|
+
if (!trustedIssuers.includes(issuer)) {
|
|
869
|
+
return { success: false, error: `Issuer ${issuer} is not trusted` };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// 3. Verify JWT against the matched issuer's JWKS
|
|
873
|
+
let payload: any;
|
|
874
|
+
try {
|
|
875
|
+
payload = await verifyJwtFromIssuer(input.token, issuer);
|
|
876
|
+
} catch {
|
|
877
|
+
return { success: false, error: "JWT verification failed" };
|
|
878
|
+
}
|
|
879
|
+
if (!payload) {
|
|
880
|
+
return { success: false, error: "JWT verification returned empty payload" };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// 4. Resolve tenant + user inside a transaction for consistency
|
|
884
|
+
return store.transaction(async () => {
|
|
885
|
+
const localTenantId = await (async () => {
|
|
886
|
+
if (!foreignTenantId) return null;
|
|
887
|
+
const existing = await store.resolveTenantByIdentity!(issuer, foreignTenantId);
|
|
888
|
+
if (existing) return existing;
|
|
889
|
+
// Auto-create tenant identity link on first encounter
|
|
890
|
+
if (store.storeTenantIdentity) {
|
|
891
|
+
await store.storeTenantIdentity(foreignTenantId, issuer, foreignTenantId);
|
|
892
|
+
}
|
|
893
|
+
return foreignTenantId;
|
|
894
|
+
})();
|
|
895
|
+
|
|
896
|
+
// 5. Resolve user
|
|
897
|
+
const localUserId = await store.resolveUserByIdentity!(issuer, sub);
|
|
898
|
+
if (localUserId) {
|
|
899
|
+
return {
|
|
900
|
+
success: true,
|
|
901
|
+
tenantId: localTenantId ?? undefined,
|
|
902
|
+
userId: localUserId,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// 4. User not linked — caller decides how to handle (e.g. OIDC flow)
|
|
907
|
+
return {
|
|
908
|
+
success: false,
|
|
909
|
+
needsAuth: true,
|
|
910
|
+
tenantId: localTenantId ?? undefined,
|
|
911
|
+
issuer,
|
|
912
|
+
sub,
|
|
913
|
+
};
|
|
914
|
+
});
|
|
808
915
|
},
|
|
809
916
|
});
|
|
810
917
|
|
|
@@ -342,7 +342,10 @@ export async function exchangeCodeForToken(
|
|
|
342
342
|
throw new Error(`Token exchange failed (${response.status}): ${text}`);
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
const
|
|
345
|
+
const responseText = await response.text();
|
|
346
|
+
console.log("[token-exchange] Slack response:", responseText.substring(0, 500));
|
|
347
|
+
let data: Record<string, unknown>;
|
|
348
|
+
try { data = JSON.parse(responseText); } catch (e) { throw new Error(`Failed to parse JSON: ${responseText.substring(0, 200)}`); }
|
|
346
349
|
|
|
347
350
|
return {
|
|
348
351
|
accessToken: String(data[oauth.accessTokenField ?? "access_token"] ?? ""),
|
|
@@ -408,7 +411,10 @@ export async function refreshAccessToken(
|
|
|
408
411
|
throw new Error(`Token refresh failed (${response.status}): ${text}`);
|
|
409
412
|
}
|
|
410
413
|
|
|
411
|
-
const
|
|
414
|
+
const responseText = await response.text();
|
|
415
|
+
console.log("[token-exchange] Slack response:", responseText.substring(0, 500));
|
|
416
|
+
let data: Record<string, unknown>;
|
|
417
|
+
try { data = JSON.parse(responseText); } catch (e) { throw new Error(`Failed to parse JSON: ${responseText.substring(0, 200)}`); }
|
|
412
418
|
|
|
413
419
|
return {
|
|
414
420
|
accessToken: String(data[oauth.accessTokenField ?? "access_token"] ?? ""),
|
|
@@ -177,6 +177,7 @@ export function createRemoteRegistryAgent(
|
|
|
177
177
|
|
|
178
178
|
// Extract setup/connect as standalone functions to avoid circular reference
|
|
179
179
|
const setupFn = async (params: Record<string, unknown>, _ctx: IntegrationMethodContext): Promise<IntegrationMethodResult> => {
|
|
180
|
+
console.log("[remote-registry] setupFn called with:", JSON.stringify(params));
|
|
180
181
|
const url = params.url as string;
|
|
181
182
|
const name = (params.name as string) ?? "registry";
|
|
182
183
|
const oidcUserId = params.oidcUserId as string | undefined;
|
|
@@ -189,7 +190,7 @@ export function createRemoteRegistryAgent(
|
|
|
189
190
|
const jwt = await signJwt({ sub: oidcUserId, action: "setup", type: "agent-registry" });
|
|
190
191
|
const tokenRes = await globalThis.fetch(baseUrl + "/oauth/token", {
|
|
191
192
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
192
|
-
body: JSON.stringify({ grant_type: "jwt_exchange", assertion: jwt, scope: "setup" }),
|
|
193
|
+
body: JSON.stringify({ grant_type: "jwt_exchange", assertion: jwt, scope: "setup", redirect_uri: params.redirect_uri ?? "" }),
|
|
193
194
|
});
|
|
194
195
|
const tokenData = await tokenRes.json() as any;
|
|
195
196
|
if (!tokenData.access_token && !tokenData.tenant_id) {
|
|
@@ -203,22 +204,30 @@ export function createRemoteRegistryAgent(
|
|
|
203
204
|
|
|
204
205
|
// Phase 1: Discover JWKS, establish trust, then request OIDC
|
|
205
206
|
const configUrl = baseUrl + "/.well-known/configuration";
|
|
207
|
+
console.log("[setupFn] fetching config:", configUrl);
|
|
206
208
|
const configRes = await globalThis.fetch(configUrl);
|
|
209
|
+
console.log("[setupFn] config status:", configRes.status);
|
|
207
210
|
if (!configRes.ok) return { success: false, error: "Failed to discover registry at " + configUrl };
|
|
208
211
|
const remoteConfig = await configRes.json() as any;
|
|
209
212
|
if (remoteConfig.jwks_uri) {
|
|
213
|
+
console.log("[setupFn] fetching JWKS:", remoteConfig.jwks_uri);
|
|
210
214
|
const jwksRes = await globalThis.fetch(remoteConfig.jwks_uri);
|
|
215
|
+
console.log("[setupFn] JWKS status:", jwksRes.status);
|
|
211
216
|
if (!jwksRes.ok) return { success: false, error: "JWKS not reachable" };
|
|
212
217
|
}
|
|
213
|
-
if (addTrustedIssuer) await addTrustedIssuer(baseUrl);
|
|
218
|
+
if (addTrustedIssuer) { console.log("[setupFn] adding trusted issuer:", baseUrl); await addTrustedIssuer(baseUrl); console.log("[setupFn] added trusted issuer"); }
|
|
214
219
|
|
|
215
220
|
// Request identity — atlas will return authorize URL for Slack OIDC
|
|
221
|
+
console.log("[setupFn] Phase 1: requesting identity via jwt_exchange");
|
|
216
222
|
const jwt = await signJwt({ action: "setup", type: "agent-registry", targetUrl: url });
|
|
223
|
+
console.log("[setupFn] POSTing to:", baseUrl + "/oauth/token");
|
|
217
224
|
const tokenRes = await globalThis.fetch(baseUrl + "/oauth/token", {
|
|
218
225
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
219
|
-
body: JSON.stringify({ grant_type: "jwt_exchange", assertion: jwt, scope: "setup" }),
|
|
226
|
+
body: JSON.stringify({ grant_type: "jwt_exchange", assertion: jwt, scope: "setup", redirect_uri: params.redirect_uri ?? "" }),
|
|
220
227
|
});
|
|
228
|
+
console.log("[setupFn] token status:", tokenRes.status);
|
|
221
229
|
const tokenData = await tokenRes.json() as any;
|
|
230
|
+
console.log("[setupFn] tokenData:", JSON.stringify(tokenData).substring(0, 300));
|
|
222
231
|
|
|
223
232
|
// If already set up (user linked), store connection directly
|
|
224
233
|
if (tokenData.access_token) {
|
package/src/index.ts
CHANGED
|
@@ -194,3 +194,4 @@ export type {
|
|
|
194
194
|
export * from "./integrations-store.js";
|
|
195
195
|
export * from "./integration-interface.js";
|
|
196
196
|
export type { ContextFactory } from "./registry.js";
|
|
197
|
+
export { createKeyManager, type KeyManager, type KeyStore, type KeyManagerOptions, type StoredKey, type KeyStatus } from "./key-manager.js";
|
|
@@ -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
|
+
});
|