@slashfi/agents-sdk 0.76.0 → 0.77.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.
@@ -43,7 +43,10 @@ import {
43
43
  dynamicClientRegistration,
44
44
  buildOAuthAuthorizeUrl,
45
45
  exchangeCodeForTokens,
46
+ probeRegistryAuth,
47
+ refreshAccessToken,
46
48
  } from "./mcp-client.js";
49
+ import type { RegistryAuthRequirement } from "./define-config.js";
47
50
 
48
51
  const CONFIG_PATH = "consumer-config.json";
49
52
  const SECRET_PREFIX = "secret:";
@@ -124,7 +127,7 @@ export interface RegistryTestResult {
124
127
  }
125
128
 
126
129
  export interface AdkRegistryApi {
127
- add(entry: RegistryEntry): Promise<void>;
130
+ add(entry: RegistryEntry): Promise<{ authRequirement?: RegistryAuthRequirement }>;
128
131
  remove(nameOrUrl: string): Promise<boolean>;
129
132
  list(): Promise<RegistryEntry[]>;
130
133
  get(name: string): Promise<RegistryEntry | null>;
@@ -132,6 +135,39 @@ export interface AdkRegistryApi {
132
135
  browse(name: string, query?: string): Promise<AgentListEntry[]>;
133
136
  inspect(name: string): Promise<RegistryConfiguration>;
134
137
  test(name?: string): Promise<RegistryTestResult[]>;
138
+ /**
139
+ * Attach a credential to a registry that returned 401 during `add`. Clears
140
+ * `authRequirement` so subsequent ops stop throwing `registry_auth_required`.
141
+ * Accepts a pre-existing token / api-key when the caller already has one.
142
+ */
143
+ auth(
144
+ nameOrUrl: string,
145
+ credential:
146
+ | { token: string; tokenUrl?: string }
147
+ | { apiKey: string; header?: string },
148
+ ): Promise<boolean>;
149
+
150
+ /**
151
+ * Resolve auth for a registry the way `adk registry auth` does — runs the
152
+ * full OAuth flow (dynamic client registration + PKCE authorize + callback
153
+ * + token exchange) when the registry advertised authorization servers,
154
+ * or spins up a local HTTPS form for bearer-token entry otherwise.
155
+ *
156
+ * Returns `{ complete: true }` once the registry has usable credentials
157
+ * persisted. The `onAuthorizeUrl` callback fires with the URL the user
158
+ * should open (browser redirect URL for OAuth, or `http://localhost/auth`
159
+ * for the token-entry form). Pass `force: true` to skip the short-circuit
160
+ * when existing credentials look syntactically valid but may be stale
161
+ * server-side — the common case when the CLI command is invoked explicitly.
162
+ */
163
+ authLocal(
164
+ nameOrUrl: string,
165
+ opts?: {
166
+ onAuthorizeUrl?: (url: string) => void;
167
+ timeoutMs?: number;
168
+ force?: boolean;
169
+ },
170
+ ): Promise<{ complete: boolean }>;
135
171
  }
136
172
 
137
173
  /** Describes a single credential field requirement */
@@ -802,19 +838,217 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
802
838
  // Registry API
803
839
  // ==========================================
804
840
 
841
+ /**
842
+ * Encrypt with `secret:` prefix when an encryption key is configured, so the
843
+ * value is readable by the existing `decryptConfigSecrets` path on the read
844
+ * side. Plaintext fallback preserves the "no key = dev mode" contract.
845
+ */
846
+ async function protectSecret(value: string): Promise<string> {
847
+ if (!options.encryptionKey) return value;
848
+ return `${SECRET_PREFIX}${await encryptSecret(value, options.encryptionKey)}`;
849
+ }
850
+
851
+ /**
852
+ * Re-probe a registry with the current stored credentials to see whether it
853
+ * advertises `capabilities.registry.proxy` in its MCP `initialize` response,
854
+ * and persist the proxy config when it does. Safe to call after a successful
855
+ * `auth()` / `authLocal()` — on the add path we skip the proxy probe when
856
+ * auth is required, so this is the second chance to back-fill it.
857
+ *
858
+ * Respects explicit user config: if `proxy` is already set, we leave it
859
+ * alone. Any discovery failure is swallowed — proxy is an optimization,
860
+ * not a correctness requirement.
861
+ */
862
+ async function discoverProxyAfterAuth(nameOrUrl: string): Promise<void> {
863
+ const config = await readConfig();
864
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
865
+ if (!target || typeof target === "string") return;
866
+ if (target.proxy) return;
867
+
868
+ try {
869
+ const consumer = await buildConsumer(nameOrUrl);
870
+ const discovered = await consumer.discover(target.url);
871
+ if (!discovered.proxy?.mode) return;
872
+ await updateRegistryEntry(nameOrUrl, (existing) => {
873
+ if (existing.proxy) return;
874
+ existing.proxy = {
875
+ mode: discovered.proxy!.mode,
876
+ ...(discovered.proxy!.agent && { agent: discovered.proxy!.agent }),
877
+ };
878
+ });
879
+ } catch {
880
+ // Proxy probe is best-effort — auth itself already succeeded.
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Atomic read-modify-write on a registry entry by name or URL. Used by
886
+ * `authLocal` to persist both `auth` and `oauth` together, which `auth()`
887
+ * alone can't express. Returns true when the entry was found and written.
888
+ */
889
+ async function updateRegistryEntry(
890
+ nameOrUrl: string,
891
+ mutate: (entry: RegistryEntry) => void,
892
+ ): Promise<boolean> {
893
+ const config = await readConfig();
894
+ if (!config.registries?.length) return false;
895
+ let found = false;
896
+ const registries = config.registries.map((r): string | RegistryEntry => {
897
+ const rName = registryDisplayName(r);
898
+ if (rName !== nameOrUrl && registryUrl(r) !== nameOrUrl) return r;
899
+ found = true;
900
+ const existing: RegistryEntry = typeof r === "string" ? { url: r } : { ...r };
901
+ mutate(existing);
902
+ return existing;
903
+ });
904
+ if (!found) return false;
905
+ await writeConfig({ ...config, registries });
906
+ return true;
907
+ }
908
+
909
+ /**
910
+ * Decrypt a `secret:`-prefixed value if we hold the encryption key. Plaintext
911
+ * values pass through unchanged so dev configs keep working.
912
+ */
913
+ async function revealSecret(value: string | undefined): Promise<string | undefined> {
914
+ if (!value) return value;
915
+ if (!value.startsWith(SECRET_PREFIX)) return value;
916
+ if (!options.encryptionKey) return undefined;
917
+ return decryptSecret(value.slice(SECRET_PREFIX.length), options.encryptionKey);
918
+ }
919
+
920
+ /**
921
+ * Refresh a registry's OAuth access token using the stored refresh token.
922
+ * Persists the new access token (encrypted) and updates `expiresAt`. If the
923
+ * provider rotates the refresh token, that's encrypted and stored too.
924
+ * Returns `true` when the refresh succeeded. Callers should catch and fall
925
+ * back to full re-auth on failure.
926
+ */
927
+ async function refreshRegistryToken(nameOrUrl: string): Promise<boolean> {
928
+ const config = await readConfig();
929
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
930
+ if (!target || typeof target === "string") return false;
931
+ const oauth = target.oauth;
932
+ if (!oauth?.refreshToken || !oauth.tokenEndpoint || !oauth.clientId) return false;
933
+
934
+ const refreshToken = await revealSecret(oauth.refreshToken);
935
+ const clientSecret = await revealSecret(oauth.clientSecret);
936
+ if (!refreshToken) return false;
937
+
938
+ const refreshed = await refreshAccessToken(oauth.tokenEndpoint, {
939
+ refreshToken,
940
+ clientId: oauth.clientId,
941
+ ...(clientSecret && { clientSecret }),
942
+ });
943
+
944
+ const expiresAt = refreshed.expiresIn
945
+ ? new Date(Date.now() + refreshed.expiresIn * 1000).toISOString()
946
+ : undefined;
947
+ const encAccess = await protectSecret(refreshed.accessToken);
948
+ const encRefresh = refreshed.refreshToken
949
+ ? await protectSecret(refreshed.refreshToken)
950
+ : undefined;
951
+
952
+ await updateRegistryEntry(nameOrUrl, (existing) => {
953
+ existing.auth = { type: "bearer", token: encAccess };
954
+ if (!existing.oauth) return;
955
+ if (encRefresh) existing.oauth.refreshToken = encRefresh;
956
+ if (expiresAt) existing.oauth.expiresAt = expiresAt;
957
+ else delete existing.oauth.expiresAt;
958
+ });
959
+ return true;
960
+ }
961
+
962
+ /**
963
+ * Run a registry op once; on 401 (`registry_auth_required`), try to refresh
964
+ * via the stored refresh token and retry exactly once. Any other AdkError
965
+ * propagates as-is.
966
+ */
967
+ async function callWithRefresh<T>(
968
+ nameOrUrl: string,
969
+ fn: () => Promise<T>,
970
+ ): Promise<T> {
971
+ try {
972
+ return await fn();
973
+ } catch (err) {
974
+ if (!(err instanceof AdkError) || err.code !== "registry_auth_required") throw err;
975
+ let refreshed = false;
976
+ try {
977
+ refreshed = await refreshRegistryToken(nameOrUrl);
978
+ } catch {
979
+ // Refresh failed — surface the original 401 below.
980
+ }
981
+ if (!refreshed) throw err;
982
+ return fn();
983
+ }
984
+ }
985
+
986
+ /**
987
+ * Throw a typed error if the registry has a recorded auth challenge and
988
+ * no usable credentials on the entry. Callers should invoke this before
989
+ * running any op that talks to the registry.
990
+ */
991
+ function assertRegistryAuthorized(entry: RegistryEntry): void {
992
+ if (!entry.authRequirement) return;
993
+ const hasUsableAuth =
994
+ entry.auth && entry.auth.type !== "none"
995
+ ? (entry.auth.type === "bearer" && !!entry.auth.token) ||
996
+ (entry.auth.type === "api-key" && !!entry.auth.key)
997
+ : false;
998
+ if (hasUsableAuth) return;
999
+
1000
+ const name = entry.name ?? entry.url;
1001
+ const scope = entry.authRequirement.scopes?.join(" ");
1002
+ throw new AdkError({
1003
+ code: "registry_auth_required",
1004
+ message: `Registry "${name}" requires authentication.`,
1005
+ hint: `Run: adk registry auth ${name} --token <token>${scope ? ` (scopes: ${scope})` : ""}`,
1006
+ details: {
1007
+ url: entry.url,
1008
+ scheme: entry.authRequirement.scheme,
1009
+ realm: entry.authRequirement.realm,
1010
+ authorizationServers: entry.authRequirement.authorizationServers,
1011
+ scopes: entry.authRequirement.scopes,
1012
+ resourceMetadataUrl: entry.authRequirement.resourceMetadataUrl,
1013
+ },
1014
+ });
1015
+ }
1016
+
805
1017
  const registry: AdkRegistryApi = {
806
- async add(entry: RegistryEntry): Promise<void> {
1018
+ async add(entry: RegistryEntry): Promise<{ authRequirement?: RegistryAuthRequirement }> {
807
1019
  const config = await readConfig();
808
1020
  const alias = entry.name ?? entry.url;
809
1021
  const registries = (config.registries ?? []).filter(
810
1022
  (r) => registryDisplayName(r) !== alias,
811
1023
  );
812
1024
 
813
- // Probe the registry's MCP initialize response so we can auto-populate
814
- // `proxy` when the server advertises it. Users who pass an explicit
815
- // `proxy` on the entry always win — discovery only fills in blanks.
1025
+ // Probe the registry before saving. Two things fall out of the probe:
1026
+ // 1. Auth challenge 401 + WWW-Authenticate points at RFC 9728
1027
+ // resource metadata; we persist it on `authRequirement` so
1028
+ // subsequent ops can refuse early with a friendly message.
1029
+ // 2. Proxy capability — the MCP `initialize` response may advertise
1030
+ // `capabilities.registry.proxy`, which auto-populates `proxy`.
1031
+ // Users who set `proxy` or `auth` explicitly on the entry always win:
1032
+ // discovery only fills in blanks.
816
1033
  let final: RegistryEntry = entry;
817
- if (!entry.proxy) {
1034
+ let authRequirement: RegistryAuthRequirement | undefined;
1035
+
1036
+ const hasUsableAuth =
1037
+ entry.auth && entry.auth.type !== "none"
1038
+ ? (entry.auth.type === "bearer" && !!entry.auth.token) ||
1039
+ (entry.auth.type === "api-key" && !!entry.auth.key)
1040
+ : false;
1041
+
1042
+ if (!hasUsableAuth) {
1043
+ const fetchFn = options.fetch ?? globalThis.fetch;
1044
+ const probe = await probeRegistryAuth(entry.url, fetchFn);
1045
+ if (probe.ok === false) {
1046
+ authRequirement = probe.requirement;
1047
+ final = { ...final, authRequirement };
1048
+ }
1049
+ }
1050
+
1051
+ if (!entry.proxy && !authRequirement) {
818
1052
  try {
819
1053
  const probeConsumer = await createRegistryConsumer(
820
1054
  { registries: [entry], refs: [] },
@@ -825,7 +1059,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
825
1059
  const discovered = await probeConsumer.discover(resolved.url);
826
1060
  if (discovered.proxy?.mode) {
827
1061
  final = {
828
- ...entry,
1062
+ ...final,
829
1063
  proxy: {
830
1064
  mode: discovered.proxy.mode,
831
1065
  ...(discovered.proxy.agent && { agent: discovered.proxy.agent }),
@@ -841,6 +1075,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
841
1075
 
842
1076
  registries.push(final);
843
1077
  await writeConfig({ ...config, registries });
1078
+ return authRequirement ? { authRequirement } : {};
844
1079
  },
845
1080
 
846
1081
  async remove(nameOrUrl: string): Promise<boolean> {
@@ -891,19 +1126,25 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
891
1126
  },
892
1127
 
893
1128
  async browse(name: string, query?: string): Promise<AgentListEntry[]> {
894
- const consumer = await buildConsumer(name);
895
1129
  const config = await readConfig();
896
1130
  const target = findRegistry(config.registries ?? [], name);
897
- const url = target ? registryUrl(target) : name;
898
- return consumer.browse(url, query);
1131
+ if (target && typeof target !== "string") assertRegistryAuthorized(target);
1132
+ return callWithRefresh(name, async () => {
1133
+ const consumer = await buildConsumer(name);
1134
+ const url = target ? registryUrl(target) : name;
1135
+ return consumer.browse(url, query);
1136
+ });
899
1137
  },
900
1138
 
901
1139
  async inspect(name: string): Promise<RegistryConfiguration> {
902
- const consumer = await buildConsumer(name);
903
1140
  const config = await readConfig();
904
1141
  const target = findRegistry(config.registries ?? [], name);
905
- const url = target ? registryUrl(target) : name;
906
- return consumer.discover(url);
1142
+ if (target && typeof target !== "string") assertRegistryAuthorized(target);
1143
+ return callWithRefresh(name, async () => {
1144
+ const consumer = await buildConsumer(name);
1145
+ const url = target ? registryUrl(target) : name;
1146
+ return consumer.discover(url);
1147
+ });
907
1148
  },
908
1149
 
909
1150
  async test(name?: string): Promise<RegistryTestResult[]> {
@@ -917,12 +1158,29 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
917
1158
  targets.map(async (r): Promise<RegistryTestResult> => {
918
1159
  const url = registryUrl(r);
919
1160
  const rName = registryDisplayName(r);
1161
+ if (typeof r !== "string" && r.authRequirement) {
1162
+ const hasUsableAuth =
1163
+ r.auth && r.auth.type !== "none"
1164
+ ? (r.auth.type === "bearer" && !!r.auth.token) ||
1165
+ (r.auth.type === "api-key" && !!r.auth.key)
1166
+ : false;
1167
+ if (!hasUsableAuth) {
1168
+ return {
1169
+ name: rName,
1170
+ url,
1171
+ status: "error",
1172
+ error: `auth required — run: adk registry auth ${rName} --token <token>`,
1173
+ };
1174
+ }
1175
+ }
920
1176
  try {
921
- const consumer = await createRegistryConsumer(
922
- { registries: [r] },
923
- { token: options.token, fetch: options.fetch },
924
- );
925
- const disc = await consumer.discover(url);
1177
+ // Route through buildConsumer so encrypted auth/headers get
1178
+ // decrypted, then use callWithRefresh so a 401 triggers the
1179
+ // stored refresh token before giving up.
1180
+ const disc = await callWithRefresh(rName, async () => {
1181
+ const consumer = await buildConsumer(rName);
1182
+ return consumer.discover(url);
1183
+ });
926
1184
  return { name: rName, url, status: "active", issuer: disc.issuer };
927
1185
  } catch (err: unknown) {
928
1186
  const msg = err instanceof Error ? err.message : "unknown";
@@ -937,6 +1195,302 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
937
1195
  : { name: "unknown", url: "unknown", status: "error" as const, error: "unknown" },
938
1196
  );
939
1197
  },
1198
+
1199
+ async auth(
1200
+ nameOrUrl: string,
1201
+ credential:
1202
+ | { token: string; tokenUrl?: string }
1203
+ | { apiKey: string; header?: string },
1204
+ ): Promise<boolean> {
1205
+ // Encrypt the secret value up-front so the write path is uniform;
1206
+ // `buildConsumer` decrypts on the read side via `decryptConfigSecrets`.
1207
+ const protectedValue =
1208
+ "token" in credential
1209
+ ? await protectSecret(credential.token)
1210
+ : await protectSecret(credential.apiKey);
1211
+
1212
+ const updated = await updateRegistryEntry(nameOrUrl, (existing) => {
1213
+ if ("token" in credential) {
1214
+ existing.auth = {
1215
+ type: "bearer",
1216
+ token: protectedValue,
1217
+ ...(credential.tokenUrl && { tokenUrl: credential.tokenUrl }),
1218
+ };
1219
+ } else {
1220
+ existing.auth = {
1221
+ type: "api-key",
1222
+ key: protectedValue,
1223
+ ...(credential.header && { header: credential.header }),
1224
+ };
1225
+ }
1226
+ delete existing.authRequirement;
1227
+ });
1228
+ if (updated) await discoverProxyAfterAuth(nameOrUrl);
1229
+ return updated;
1230
+ },
1231
+
1232
+ async authLocal(
1233
+ nameOrUrl: string,
1234
+ opts?: {
1235
+ onAuthorizeUrl?: (url: string) => void;
1236
+ timeoutMs?: number;
1237
+ force?: boolean;
1238
+ },
1239
+ ): Promise<{ complete: boolean }> {
1240
+ const config = await readConfig();
1241
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
1242
+ if (!target || typeof target === "string") {
1243
+ throw new AdkError({
1244
+ code: "registry_not_found",
1245
+ message: `Registry not found: ${nameOrUrl}`,
1246
+ hint: "Run `adk registry list` to see configured registries.",
1247
+ details: { nameOrUrl },
1248
+ });
1249
+ }
1250
+
1251
+ // When the caller forces re-auth, wipe the existing credentials and
1252
+ // re-probe so we know what scheme the registry wants now. Servers can
1253
+ // rotate auth server metadata between runs.
1254
+ if (opts?.force) {
1255
+ await updateRegistryEntry(nameOrUrl, (existing) => {
1256
+ delete existing.auth;
1257
+ delete existing.oauth;
1258
+ });
1259
+ const fetchFn = options.fetch ?? globalThis.fetch;
1260
+ const probe = await probeRegistryAuth(target.url, fetchFn);
1261
+ if (probe.ok === false) {
1262
+ await updateRegistryEntry(nameOrUrl, (existing) => {
1263
+ existing.authRequirement = probe.requirement;
1264
+ });
1265
+ // Re-read so the flow below sees the fresh requirement.
1266
+ const refreshed = await readConfig();
1267
+ const refreshedTarget = findRegistry(refreshed.registries ?? [], nameOrUrl);
1268
+ if (refreshedTarget && typeof refreshedTarget !== "string") {
1269
+ Object.assign(target, refreshedTarget);
1270
+ }
1271
+ } else if (probe.ok === true) {
1272
+ // Registry no longer requires auth — nothing to do.
1273
+ await updateRegistryEntry(nameOrUrl, (existing) => {
1274
+ delete existing.authRequirement;
1275
+ });
1276
+ return { complete: true };
1277
+ }
1278
+ }
1279
+
1280
+ // Already authenticated — nothing to do (unless forced above).
1281
+ const hasUsableAuth =
1282
+ target.auth && target.auth.type !== "none"
1283
+ ? (target.auth.type === "bearer" && !!target.auth.token) ||
1284
+ (target.auth.type === "api-key" && !!target.auth.key)
1285
+ : false;
1286
+ if (hasUsableAuth && !target.authRequirement) {
1287
+ return { complete: true };
1288
+ }
1289
+
1290
+ const req = target.authRequirement;
1291
+ const port = options.oauthCallbackPort ?? 8919;
1292
+ const timeout = opts?.timeoutMs ?? 300_000;
1293
+ const displayName = target.name ?? target.url;
1294
+ const { createServer } = await import("node:http");
1295
+
1296
+ // OAuth path — the registry advertised authorization servers via
1297
+ // RFC 9728 protected-resource metadata. Walk the full flow:
1298
+ // AS metadata → dynamic client registration → PKCE authorize →
1299
+ // local callback → token exchange → persist access token.
1300
+ if (req?.authorizationServers?.length) {
1301
+ const authServer = req.authorizationServers[0]!;
1302
+ const metadata =
1303
+ (await discoverOAuthMetadata(authServer)) ??
1304
+ (await tryFetchOAuthMetadata(authServer));
1305
+ if (!metadata) {
1306
+ throw new AdkError({
1307
+ code: "registry_oauth_discovery_failed",
1308
+ message: `Could not discover OAuth metadata at ${authServer}.`,
1309
+ hint: "The authorization server must expose /.well-known/oauth-authorization-server.",
1310
+ details: { authServer, registry: displayName },
1311
+ });
1312
+ }
1313
+ if (!metadata.registration_endpoint) {
1314
+ throw new AdkError({
1315
+ code: "registry_oauth_no_registration",
1316
+ message: `Authorization server ${authServer} does not support dynamic client registration.`,
1317
+ hint: `Obtain a bearer token manually, then run: adk registry auth ${displayName} --token <token>`,
1318
+ details: { authServer, registry: displayName },
1319
+ });
1320
+ }
1321
+
1322
+ const redirectUri = `http://localhost:${port}/callback`;
1323
+ const registration = await dynamicClientRegistration(
1324
+ metadata.registration_endpoint,
1325
+ {
1326
+ clientName: options.oauthClientName ?? "adk",
1327
+ redirectUris: [redirectUri],
1328
+ grantTypes: ["authorization_code"],
1329
+ },
1330
+ );
1331
+
1332
+ const state = crypto.randomUUID();
1333
+ const { url: authorizeUrl, codeVerifier } = await buildOAuthAuthorizeUrl({
1334
+ authorizationEndpoint: metadata.authorization_endpoint,
1335
+ clientId: registration.clientId,
1336
+ redirectUri,
1337
+ scopes: req.scopes,
1338
+ state,
1339
+ });
1340
+
1341
+ return new Promise<{ complete: boolean }>((resolve, reject) => {
1342
+ const server = createServer(async (reqIn, resOut) => {
1343
+ const reqUrl = new URL(reqIn.url ?? "/", `http://localhost:${port}`);
1344
+ if (reqUrl.pathname !== "/callback") {
1345
+ resOut.writeHead(404);
1346
+ resOut.end();
1347
+ return;
1348
+ }
1349
+
1350
+ const code = reqUrl.searchParams.get("code");
1351
+ const returnedState = reqUrl.searchParams.get("state");
1352
+ if (!code || returnedState !== state) {
1353
+ const error = reqUrl.searchParams.get("error") ?? "missing code/state";
1354
+ resOut.writeHead(400, { "Content-Type": "text/html" });
1355
+ resOut.end(`<h1>Error</h1><p>${esc(error)}</p>`);
1356
+ server.close();
1357
+ reject(
1358
+ new AdkError({
1359
+ code: "registry_oauth_denied",
1360
+ message: `OAuth callback rejected: ${error}`,
1361
+ hint: "Retry `adk registry auth` and complete the browser consent.",
1362
+ details: { registry: displayName, error },
1363
+ }),
1364
+ );
1365
+ return;
1366
+ }
1367
+
1368
+ try {
1369
+ const tokens = await exchangeCodeForTokens(metadata.token_endpoint, {
1370
+ code,
1371
+ codeVerifier,
1372
+ clientId: registration.clientId,
1373
+ clientSecret: registration.clientSecret,
1374
+ redirectUri,
1375
+ });
1376
+ const expiresAt = tokens.expiresIn
1377
+ ? new Date(Date.now() + tokens.expiresIn * 1000).toISOString()
1378
+ : undefined;
1379
+ const encToken = await protectSecret(tokens.accessToken);
1380
+ const encRefresh = tokens.refreshToken
1381
+ ? await protectSecret(tokens.refreshToken)
1382
+ : undefined;
1383
+ const encClientSecret = registration.clientSecret
1384
+ ? await protectSecret(registration.clientSecret)
1385
+ : undefined;
1386
+ await updateRegistryEntry(displayName, (existing) => {
1387
+ existing.auth = { type: "bearer", token: encToken };
1388
+ existing.oauth = {
1389
+ tokenEndpoint: metadata.token_endpoint,
1390
+ clientId: registration.clientId,
1391
+ ...(encClientSecret && { clientSecret: encClientSecret }),
1392
+ ...(encRefresh && { refreshToken: encRefresh }),
1393
+ ...(expiresAt && { expiresAt }),
1394
+ ...(req.scopes?.length && { scopes: req.scopes }),
1395
+ };
1396
+ delete existing.authRequirement;
1397
+ });
1398
+ await discoverProxyAfterAuth(displayName);
1399
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1400
+ resOut.end(renderAuthSuccess(displayName));
1401
+ server.close();
1402
+ resolve({ complete: true });
1403
+ } catch (err) {
1404
+ resOut.writeHead(500, { "Content-Type": "text/html" });
1405
+ resOut.end(
1406
+ `<h1>Error</h1><p>${esc(err instanceof Error ? err.message : String(err))}</p>`,
1407
+ );
1408
+ server.close();
1409
+ reject(err);
1410
+ }
1411
+ });
1412
+
1413
+ server.listen(port, () => {
1414
+ opts?.onAuthorizeUrl?.(authorizeUrl);
1415
+ });
1416
+
1417
+ const timer = setTimeout(() => {
1418
+ server.close();
1419
+ reject(new Error("OAuth callback timed out"));
1420
+ }, timeout);
1421
+ server.on("close", () => clearTimeout(timer));
1422
+ });
1423
+ }
1424
+
1425
+ // No OAuth metadata — serve a local HTTPS form asking for a token.
1426
+ // Used when the registry returned 401 without pointing at an AS, or
1427
+ // when the caller simply wants to paste a pre-issued token.
1428
+ const fields: AuthChallengeField[] = [
1429
+ {
1430
+ name: "token",
1431
+ label: "Bearer token",
1432
+ description: req?.realm
1433
+ ? `Token for realm "${req.realm}"`
1434
+ : "Token sent as `Authorization: Bearer <token>`.",
1435
+ secret: true,
1436
+ },
1437
+ ];
1438
+
1439
+ return new Promise<{ complete: boolean }>((resolve, reject) => {
1440
+ const server = createServer(async (reqIn, resOut) => {
1441
+ const reqUrl = new URL(reqIn.url ?? "/", `http://localhost:${port}`);
1442
+
1443
+ if (reqIn.method === "GET" && reqUrl.pathname === "/auth") {
1444
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1445
+ resOut.end(renderCredentialForm(displayName, fields));
1446
+ return;
1447
+ }
1448
+
1449
+ if (reqIn.method === "POST" && reqUrl.pathname === "/auth") {
1450
+ const chunks: Buffer[] = [];
1451
+ for await (const chunk of reqIn) chunks.push(chunk as Buffer);
1452
+ const body = Buffer.concat(chunks).toString();
1453
+ const params = new URLSearchParams(body);
1454
+ const token = params.get("token");
1455
+ if (!token) {
1456
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1457
+ resOut.end(renderCredentialForm(displayName, fields, "Token is required."));
1458
+ return;
1459
+ }
1460
+ try {
1461
+ await registry.auth(displayName, { token });
1462
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1463
+ resOut.end(renderAuthSuccess(displayName));
1464
+ server.close();
1465
+ resolve({ complete: true });
1466
+ } catch (err) {
1467
+ resOut.writeHead(500, { "Content-Type": "text/html" });
1468
+ resOut.end(
1469
+ renderCredentialForm(
1470
+ displayName,
1471
+ fields,
1472
+ err instanceof Error ? err.message : String(err),
1473
+ ),
1474
+ );
1475
+ }
1476
+ return;
1477
+ }
1478
+
1479
+ resOut.writeHead(404);
1480
+ resOut.end();
1481
+ });
1482
+
1483
+ server.listen(port, () => {
1484
+ opts?.onAuthorizeUrl?.(`http://localhost:${port}/auth`);
1485
+ });
1486
+
1487
+ const timer = setTimeout(() => {
1488
+ server.close();
1489
+ reject(new Error("Auth timed out"));
1490
+ }, timeout);
1491
+ server.on("close", () => clearTimeout(timer));
1492
+ });
1493
+ },
940
1494
  };
941
1495
 
942
1496
  // ==========================================