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