@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.
@@ -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
- connectBaseUrl: {
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
- _input: { token: string; connectBaseUrl?: string },
838
+ input: { token: string },
795
839
  ) => {
796
- // This tool is a stub — the actual implementation needs:
797
- // 1. JWT verification (via verifyJwtFromIssuer)
798
- // 2. Tenant resolution (via tenant_identity table)
799
- // 3. User resolution (via user_identity table)
800
- // These depend on the store having identity lookup methods.
801
- //
802
- // For now, return the structure so the flow can be wired.
803
- // The atlas-environments CockroachDB implementation overrides this.
804
- return {
805
- error: "exchange_token requires a store with identity resolution support",
806
- hint: "Override this tool in your environment implementation",
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 data = (await response.json()) as Record<string, unknown>;
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 data = (await response.json()) as Record<string, unknown>;
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
+ });