@agentxm/registry-auth 0.28.4-bootstrap.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.
Files changed (44) hide show
  1. package/LICENSE +110 -0
  2. package/dist/src/auth-client.d.ts +199 -0
  3. package/dist/src/auth-client.js +638 -0
  4. package/dist/src/auth-middleware.d.ts +28 -0
  5. package/dist/src/auth-middleware.js +105 -0
  6. package/dist/src/credential-store.d.ts +77 -0
  7. package/dist/src/credential-store.js +443 -0
  8. package/dist/src/device-login.d.ts +114 -0
  9. package/dist/src/device-login.js +300 -0
  10. package/dist/src/errors.d.ts +141 -0
  11. package/dist/src/errors.js +84 -0
  12. package/dist/src/guard.d.ts +24 -0
  13. package/dist/src/guard.js +62 -0
  14. package/dist/src/index.d.ts +36 -0
  15. package/dist/src/index.js +31 -0
  16. package/dist/src/internal/environment.d.ts +23 -0
  17. package/dist/src/internal/environment.js +45 -0
  18. package/dist/src/live.d.ts +13 -0
  19. package/dist/src/live.js +13 -0
  20. package/dist/src/login-interaction.d.ts +41 -0
  21. package/dist/src/login-interaction.js +108 -0
  22. package/dist/src/login-output.d.ts +22 -0
  23. package/dist/src/login-output.js +32 -0
  24. package/dist/src/login-presenter.d.ts +95 -0
  25. package/dist/src/login-presenter.js +58 -0
  26. package/dist/src/login-strategy.d.ts +21 -0
  27. package/dist/src/login-strategy.js +25 -0
  28. package/dist/src/loopback-login.d.ts +19 -0
  29. package/dist/src/loopback-login.js +91 -0
  30. package/dist/src/loopback-server.d.ts +40 -0
  31. package/dist/src/loopback-server.js +168 -0
  32. package/dist/src/oauth-contract.d.ts +7 -0
  33. package/dist/src/oauth-contract.js +2 -0
  34. package/dist/src/pending-device-login-store.d.ts +37 -0
  35. package/dist/src/pending-device-login-store.js +122 -0
  36. package/dist/src/publish-authorization.d.ts +11 -0
  37. package/dist/src/publish-authorization.js +79 -0
  38. package/dist/src/schema.d.ts +78 -0
  39. package/dist/src/schema.js +55 -0
  40. package/dist/src/testing.d.ts +14 -0
  41. package/dist/src/testing.js +14 -0
  42. package/dist/src/token-resolution.d.ts +79 -0
  43. package/dist/src/token-resolution.js +190 -0
  44. package/package.json +62 -0
@@ -0,0 +1,638 @@
1
+ // @effect-diagnostics anyUnknownInErrorContext:off — HTTP schema/status errors remain opaque only inside this translating adapter
2
+ /**
3
+ * AuthClient Effect service — device flow login, token refresh, revocation, identity queries.
4
+ *
5
+ * Provides methods for the OAuth 2.0 Device Authorization Grant (RFC 8628)
6
+ * and related auth operations against the AgentXM registry API.
7
+ *
8
+ * Uses the generated registry client for HTTP transport and surfaces typed
9
+ * auth and registry failures; the application boundary owns envelope
10
+ * rendering.
11
+ *
12
+ * @experimental This API is unstable and may change without notice.
13
+ */
14
+ import * as HttpClient from "effect/unstable/http/HttpClient";
15
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
16
+ import * as Data from "effect/Data";
17
+ import * as DateTime from "effect/DateTime";
18
+ import * as Duration from "effect/Duration";
19
+ import * as ServiceMap from "effect/Context";
20
+ import * as Effect from "effect/Effect";
21
+ import * as Layer from "effect/Layer";
22
+ import * as Schedule from "effect/Schedule";
23
+ import * as Schema from "effect/Schema";
24
+ import { DateTimeUtcSchema } from "@agentxm/extension-model/unstable/date-time";
25
+ import { normalizeHandle } from "@agentxm/extension-model/unstable/extensions/handle";
26
+ import { PublishVisibilitySchema, } from "@agentxm/registry-protocol/unstable/publish/visibility";
27
+ import { PreviewPublicationSetResponseSchema, } from "@agentxm/registry-protocol/unstable/registry/publication-set";
28
+ import {} from "./oauth-contract.js";
29
+ import { GeneratedRegistryClient, RegistryUrl, captureRegistryErrorResponseBodies, getString, isHttpClientError, isRegistryClientError, isRegistryClientFailure, isTransientHttpClientError, mapRegistryFailure, } from "@agentxm/registry-client";
30
+ import { AuthExchangeFailed, DeviceLoginCodeExpired, DeviceLoginDenied, RegistryAuthFailed, StepUpRequired, } from "./errors.js";
31
+ // -----------------------------------------------------------------------------
32
+ // Constants
33
+ // -----------------------------------------------------------------------------
34
+ const CLIENT_ID = "axm-cli";
35
+ export const OIDC_LOGIN_SCOPES = ["openid", "profile", "email", "offline_access"];
36
+ export const BASELINE_REGISTRY_LOGIN_SCOPES = ["extensions:read", "account:read"];
37
+ export const DEFAULT_LOGIN_SCOPES = [
38
+ ...OIDC_LOGIN_SCOPES,
39
+ ...BASELINE_REGISTRY_LOGIN_SCOPES,
40
+ ];
41
+ const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
42
+ const AUTHORIZATION_CODE_GRANT_TYPE = "authorization_code";
43
+ const SLOW_DOWN_INCREMENT_MS = 5000;
44
+ const TRANSIENT_DEVICE_POLL_RETRY_COUNT = 2;
45
+ const TRANSIENT_DEVICE_POLL_RETRY_BASE_DELAY = "250 millis";
46
+ export const normalizeRequestedLoginScopes = (scopes = DEFAULT_LOGIN_SCOPES) => Array.from(new Set([...OIDC_LOGIN_SCOPES, ...scopes].map((scope) => scope.trim()).filter(Boolean))).sort();
47
+ export class AuthClient extends ServiceMap.Service()("@agentxm/registry-auth/auth-client/AuthClient") {
48
+ }
49
+ // -----------------------------------------------------------------------------
50
+ // Internal helpers
51
+ // -----------------------------------------------------------------------------
52
+ class RetryableDevicePollError extends Data.TaggedError("RetryableDevicePollError") {
53
+ }
54
+ class OAuthTokenResponseError extends Data.TaggedError("OAuthTokenResponseError") {
55
+ }
56
+ const retryAfterSeconds = (value, fallback) => {
57
+ if (value === undefined)
58
+ return fallback;
59
+ const parsed = Number(value);
60
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.ceil(parsed) : fallback;
61
+ };
62
+ /** Normalize a generated token response to our domain NormalizedTokenResponse. */
63
+ const normalizeTokenResponse = (token) => ({
64
+ access_token: token.access_token,
65
+ refresh_token: token.refresh_token,
66
+ expires_at: token.expires_at,
67
+ });
68
+ const SessionTokenResponseSchema = Schema.Struct({
69
+ access_token: Schema.String,
70
+ refresh_token: Schema.String,
71
+ expires_at: DateTimeUtcSchema,
72
+ });
73
+ const PublishCapabilityResponseSchema = Schema.Struct({
74
+ access_token: Schema.String,
75
+ expires_at: DateTimeUtcSchema,
76
+ scope: Schema.String,
77
+ publish_request_id: Schema.String,
78
+ visibility_contract: Schema.Literal("v2"),
79
+ visibility: PublishVisibilitySchema,
80
+ condition: Schema.String.check(Schema.isMinLength(1)),
81
+ publication_set_digest: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
82
+ publication_descriptor_digest: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
83
+ });
84
+ const PublishAuthorizationExchangeResponseSchema = Schema.Union([
85
+ Schema.Struct({
86
+ status: Schema.Literal("admitted"),
87
+ preview: PreviewPublicationSetResponseSchema,
88
+ grants: Schema.Array(PublishCapabilityResponseSchema),
89
+ }),
90
+ Schema.Struct({
91
+ status: Schema.Literal("blocked"),
92
+ preview: PreviewPublicationSetResponseSchema,
93
+ grants: Schema.Tuple([]),
94
+ }),
95
+ ]);
96
+ const deriveAuthorizationOrigin = (registryUrl) => {
97
+ const url = new URL(registryUrl);
98
+ if (url.origin === "https://registry.agentxm.ai") {
99
+ return "https://agentxm.ai";
100
+ }
101
+ if (url.origin === "https://registry-dev.agentxm-ai.workers.dev") {
102
+ return "https://web-dev.agentxm-ai.workers.dev";
103
+ }
104
+ if (url.host === "localhost:4300") {
105
+ return "http://localhost:4200";
106
+ }
107
+ if (url.host === "127.0.0.1:4300") {
108
+ return "http://127.0.0.1:4200";
109
+ }
110
+ if (url.hostname === "127.0.0.1") {
111
+ return `${url.protocol}//${url.hostname}:4200`;
112
+ }
113
+ return url.origin;
114
+ };
115
+ const getOAuthErrorCode = (error) => getString(error, "error") ?? getString(error, "code");
116
+ const isRetryableDevicePollError = (error) => error._tag === "RetryableDevicePollError";
117
+ const registryAuthFailure = (registryUrl, operation, error) => isRegistryClientFailure(error)
118
+ ? error
119
+ : mapRegistryFailure(error, {
120
+ baseUrl: registryUrl,
121
+ networkDetail: `${operation}: the Registry could not be reached.`,
122
+ incompatibleDetail: `${operation}: the Registry response does not match the expected contract.`,
123
+ requestConstructionDetail: `${operation}: the Registry request could not be constructed.`,
124
+ fallbackDetail: operation,
125
+ });
126
+ const mapRegistryAuthError = (registryUrl, operation, error) => registryAuthFailure(registryUrl, operation, error);
127
+ const makeGeneratedAuthClient = (httpClient, registryUrl, accessToken, stepUpRequestId) => {
128
+ const remoteHttpClient = httpClient.pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl(registryUrl)));
129
+ const authedHttpClient = accessToken === undefined
130
+ ? remoteHttpClient
131
+ : remoteHttpClient.pipe(HttpClient.mapRequest(HttpClientRequest.bearerToken(accessToken)));
132
+ const registryHttpClient = captureRegistryErrorResponseBodies(stepUpRequestId === undefined
133
+ ? authedHttpClient
134
+ : authedHttpClient.pipe(HttpClient.mapRequest(HttpClientRequest.setHeaders({ "x-axm-step-up-request": stepUpRequestId }))));
135
+ return GeneratedRegistryClient.make(registryHttpClient);
136
+ };
137
+ const isRecord = (value) => typeof value === "object" && value !== null;
138
+ const readString = (record, key) => {
139
+ const value = record[key];
140
+ return typeof value === "string" ? value : null;
141
+ };
142
+ const readInteger = (record, key) => {
143
+ const value = record[key];
144
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
145
+ };
146
+ const readExpiry = (record) => {
147
+ const value = record["expires_at"];
148
+ if (typeof value === "string")
149
+ return value;
150
+ return DateTime.isDateTime(value) ? DateTime.formatIso(value) : null;
151
+ };
152
+ export const readStepUpRequest = (error) => {
153
+ const body = error.metadata?.response?.body;
154
+ if (!isRecord(body) || readString(body, "code") !== "eotp")
155
+ return null;
156
+ const wire = body["step_up"];
157
+ if (!isRecord(wire))
158
+ return null;
159
+ const requestId = readString(wire, "request_id");
160
+ const verificationUrl = readString(wire, "verification_url");
161
+ const statusUrl = readString(wire, "status_url");
162
+ const expiresAt = readExpiry(wire);
163
+ const intervalSeconds = readInteger(wire, "interval");
164
+ const action = readString(wire, "action");
165
+ const target = readString(wire, "target");
166
+ if (requestId === null ||
167
+ verificationUrl === null ||
168
+ statusUrl === null ||
169
+ expiresAt === null ||
170
+ intervalSeconds === null ||
171
+ action === null ||
172
+ target === null) {
173
+ return null;
174
+ }
175
+ const maxAgeSeconds = readInteger(body, "max_age");
176
+ return {
177
+ requestId,
178
+ verificationUrl,
179
+ statusUrl,
180
+ expiresAt,
181
+ intervalSeconds,
182
+ ...(maxAgeSeconds === null ? {} : { maxAgeSeconds }),
183
+ action,
184
+ target,
185
+ };
186
+ };
187
+ /**
188
+ * Retry transient device-poll failures with exponential backoff, capped at
189
+ * TRANSIENT_DEVICE_POLL_RETRY_COUNT attempts. Non-retryable failures bypass
190
+ * the retry via the `while` predicate, and any RetryableDevicePollError that
191
+ * survives retry exhaustion is translated to a typed registry failure.
192
+ */
193
+ const retryTransientDevicePollFailure = (registryUrl, effect) => effect.pipe(Effect.retry({
194
+ times: TRANSIENT_DEVICE_POLL_RETRY_COUNT,
195
+ schedule: Schedule.exponential(TRANSIENT_DEVICE_POLL_RETRY_BASE_DELAY),
196
+ while: isRetryableDevicePollError,
197
+ }), Effect.catchTag("RetryableDevicePollError", (e) => Effect.fail(registryAuthFailure(registryUrl, "Device token exchange failed", e.cause))));
198
+ // -----------------------------------------------------------------------------
199
+ // Single poll step
200
+ // -----------------------------------------------------------------------------
201
+ const postTokenForm = (httpClient, registryUrl, body) => {
202
+ const client = makeGeneratedAuthClient(httpClient, registryUrl);
203
+ return client.AuthExchangeToken({ payload: body }).pipe(Effect.catch((error) => {
204
+ const oauthCode = isRegistryClientError("AuthExchangeToken400")(error)
205
+ ? getOAuthErrorCode(error.cause)
206
+ : undefined;
207
+ return Effect.fail(new OAuthTokenResponseError({
208
+ ...(oauthCode === undefined ? {} : { oauthCode }),
209
+ cause: error,
210
+ retryable: isTransientHttpClientError(error),
211
+ }));
212
+ }), Effect.flatMap((response) => Schema.is(SessionTokenResponseSchema)(response)
213
+ ? Effect.succeed(response)
214
+ : Effect.fail(registryAuthFailure(registryUrl, "Token exchange failed: the Registry response does not match the expected contract", response))), Effect.map(normalizeTokenResponse));
215
+ };
216
+ /**
217
+ * Internal: execute a single device token poll against the OAuth token endpoint.
218
+ *
219
+ * Surfaces transient HTTP failures as RetryableDevicePollError so callers can
220
+ * decide whether to retry; other failures surface as typed registry failures.
221
+ *
222
+ * @param httpClient - Effect HTTP client
223
+ * @param registryUrl - Registry API origin
224
+ * @param deviceCode - Device verification code from the initial authorization
225
+ */
226
+ const pollOnceInternal = (httpClient, registryUrl, deviceCode) => postTokenForm(httpClient, registryUrl, {
227
+ client_id: CLIENT_ID,
228
+ device_code: deviceCode,
229
+ grant_type: DEVICE_CODE_GRANT_TYPE,
230
+ }).pipe(Effect.map((token) => ({
231
+ _tag: "Success",
232
+ token,
233
+ })), Effect.catch((error) => {
234
+ if (isRegistryClientFailure(error))
235
+ return Effect.fail(error);
236
+ const code = error.oauthCode;
237
+ switch (code) {
238
+ case "authorization_pending":
239
+ return Effect.succeed({ _tag: "Pending" });
240
+ case "slow_down":
241
+ return Effect.succeed({ _tag: "SlowDown" });
242
+ case "access_denied":
243
+ return Effect.succeed({ _tag: "AccessDenied" });
244
+ case "expired_token":
245
+ return Effect.succeed({ _tag: "ExpiredToken" });
246
+ default:
247
+ break;
248
+ }
249
+ if (error.retryable) {
250
+ return Effect.fail(new RetryableDevicePollError({ cause: error.cause }));
251
+ }
252
+ return Effect.fail(registryAuthFailure(registryUrl, "Token exchange failed", error.cause));
253
+ }));
254
+ /**
255
+ * Execute a single device token poll (exported for testing).
256
+ *
257
+ * Transient HTTP failures are collapsed into AUTH_LOGIN_FAILED; this seam does
258
+ * not retry on its own. For the retrying variant, use `pollDeviceToken`.
259
+ */
260
+ export const pollOnce = (httpClient, registryUrl, deviceCode) => pollOnceInternal(httpClient, registryUrl, deviceCode).pipe(Effect.catchTag("RetryableDevicePollError", (e) => Effect.fail(registryAuthFailure(registryUrl, "Device token exchange failed", e.cause))));
261
+ // -----------------------------------------------------------------------------
262
+ // Live layer
263
+ // -----------------------------------------------------------------------------
264
+ export const AuthClientLive = Layer.effect(AuthClient, Effect.gen(function* () {
265
+ const httpClient = yield* HttpClient.HttpClient;
266
+ const registryUrl = yield* RegistryUrl;
267
+ const authorizationOrigin = deriveAuthorizationOrigin(registryUrl);
268
+ const client = makeGeneratedAuthClient(httpClient, registryUrl);
269
+ const buildAuthorizeUrl = ({ challenge, expiresAt, state, redirectUri, scopes, }) => {
270
+ const url = new URL("/oauth/authorize", authorizationOrigin);
271
+ url.searchParams.set("response_type", "code");
272
+ url.searchParams.set("client_id", CLIENT_ID);
273
+ url.searchParams.set("code_challenge", challenge);
274
+ url.searchParams.set("code_challenge_method", "S256");
275
+ url.searchParams.set("state", state);
276
+ url.searchParams.set("redirect_uri", redirectUri);
277
+ url.searchParams.set("scope", normalizeRequestedLoginScopes(scopes).join(" "));
278
+ if (expiresAt !== undefined) {
279
+ url.searchParams.set("request_expires_at", DateTime.formatIso(expiresAt));
280
+ }
281
+ return url.href;
282
+ };
283
+ const getAuthorizationIssuer = () => authorizationOrigin;
284
+ const exchangePkceCode = Effect.fn("AuthClient.exchangePkceCode")(function* ({ code, verifier, redirectUri }) {
285
+ const response = yield* postTokenForm(httpClient, registryUrl, {
286
+ grant_type: AUTHORIZATION_CODE_GRANT_TYPE,
287
+ code,
288
+ code_verifier: verifier,
289
+ client_id: CLIENT_ID,
290
+ redirect_uri: redirectUri,
291
+ }).pipe(Effect.catchTag("OAuthTokenResponseError", (error) => Effect.fail(new AuthExchangeFailed({
292
+ detail: "Authorization code exchange failed",
293
+ suggestions: [{ description: "Try signing in again.", cmd: "axm login" }],
294
+ failure: registryAuthFailure(registryUrl, "Token exchange failed", error.cause),
295
+ }))));
296
+ return response;
297
+ });
298
+ const createPublishAuthorizationRequest = Effect.fn("AuthClient.createPublishAuthorizationRequest")(function* (params) {
299
+ const publishClient = makeGeneratedAuthClient(httpClient, params.registryUrl);
300
+ const publicationSet = yield* Schema.encodeUnknownEffect(GeneratedRegistryClient.PreviewPublicationSetRequest)(params.publicationSet).pipe(Effect.mapError((error) => mapRegistryAuthError(params.registryUrl, "Could not encode publish authorization request", error)));
301
+ const response = yield* publishClient
302
+ .AuthCreatePublishAuthorizationRequest({
303
+ payload: {
304
+ client_id: CLIENT_ID,
305
+ redirect_uri: params.redirectUri,
306
+ state: params.state,
307
+ code_challenge: params.codeChallenge,
308
+ code_challenge_method: "S256",
309
+ publication_set: publicationSet,
310
+ },
311
+ })
312
+ .pipe(Effect.mapError((error) => mapRegistryAuthError(params.registryUrl, "Could not create publish authorization request", error)));
313
+ return {
314
+ requestId: response.request_id,
315
+ authorizationUrl: response.authorization_url,
316
+ expiresAt: response.expires_at,
317
+ };
318
+ });
319
+ const exchangePublishAuthorizationCode = Effect.fn("AuthClient.exchangePublishAuthorizationCode")(function* (params) {
320
+ const publishClient = makeGeneratedAuthClient(httpClient, params.registryUrl);
321
+ const response = yield* publishClient
322
+ .AuthExchangeToken({
323
+ payload: {
324
+ grant_type: AUTHORIZATION_CODE_GRANT_TYPE,
325
+ code: params.code,
326
+ code_verifier: params.verifier,
327
+ client_id: CLIENT_ID,
328
+ redirect_uri: params.redirectUri,
329
+ },
330
+ })
331
+ .pipe(Effect.mapError((error) => {
332
+ const mapped = mapRegistryAuthError(params.registryUrl, "Publish authorization code exchange failed", error);
333
+ const code = isRegistryClientError("AuthExchangeToken400")(error)
334
+ ? getOAuthErrorCode(error.cause)
335
+ : undefined;
336
+ return code === "invalid_grant"
337
+ ? new AuthExchangeFailed({
338
+ detail: "Publish authorization expired or was already used",
339
+ suggestions: [
340
+ {
341
+ description: "Review the exact publish request again by rerunning publish.",
342
+ },
343
+ ],
344
+ failure: mapped,
345
+ })
346
+ : mapped;
347
+ }));
348
+ if (!Schema.is(PublishAuthorizationExchangeResponseSchema)(response)) {
349
+ return yield* mapRegistryAuthError(params.registryUrl, "The Registry is incompatible with exact publish authorization", response);
350
+ }
351
+ if (response.status === "blocked") {
352
+ return response;
353
+ }
354
+ return {
355
+ status: "admitted",
356
+ preview: response.preview,
357
+ grants: response.grants.map((grant) => ({
358
+ accessToken: grant.access_token,
359
+ expiresAt: grant.expires_at,
360
+ scope: grant.scope,
361
+ publishRequestId: grant.publish_request_id,
362
+ visibilityContract: grant.visibility_contract,
363
+ visibility: grant.visibility,
364
+ condition: grant.condition,
365
+ publicationSetDigest: grant.publication_set_digest,
366
+ publicationDescriptorDigest: grant.publication_descriptor_digest,
367
+ })),
368
+ };
369
+ });
370
+ const initiateDeviceFlow = Effect.fn("AuthClient.initiateDeviceFlow")(function* (options) {
371
+ const response = yield* client
372
+ .AuthIssueDeviceCode({
373
+ payload: {
374
+ client_id: CLIENT_ID,
375
+ scope: normalizeRequestedLoginScopes(options?.scopes).join(" "),
376
+ },
377
+ })
378
+ .pipe(Effect.mapError((error) => mapRegistryAuthError(registryUrl, "Could not initiate device sign-in", error)));
379
+ return {
380
+ device_code: response.device_code,
381
+ user_code: response.user_code,
382
+ verification_uri: response.verification_uri,
383
+ verification_uri_complete: response.verification_uri_complete,
384
+ interval: response.interval,
385
+ expires_in: response.expires_in,
386
+ };
387
+ });
388
+ const pollDeviceToken = Effect.fn("AuthClient.pollDeviceToken")(function* (deviceCode, interval) {
389
+ let currentInterval = interval * 1000;
390
+ while (true) {
391
+ yield* Effect.sleep(currentInterval);
392
+ const result = yield* retryTransientDevicePollFailure(registryUrl, pollOnceInternal(httpClient, registryUrl, deviceCode));
393
+ switch (result._tag) {
394
+ case "Success":
395
+ return result.token;
396
+ case "Pending":
397
+ continue;
398
+ case "SlowDown":
399
+ currentInterval += SLOW_DOWN_INCREMENT_MS;
400
+ continue;
401
+ case "AccessDenied":
402
+ return yield* new DeviceLoginDenied();
403
+ case "ExpiredToken":
404
+ return yield* new DeviceLoginCodeExpired();
405
+ }
406
+ }
407
+ });
408
+ const refreshToken = Effect.fn("AuthClient.refreshToken")(function* (refreshTokenValue) {
409
+ return yield* postTokenForm(httpClient, registryUrl, {
410
+ grant_type: "refresh_token",
411
+ refresh_token: refreshTokenValue,
412
+ client_id: CLIENT_ID,
413
+ }).pipe(Effect.catchTag("OAuthTokenResponseError", (error) => Effect.fail(new AuthExchangeFailed({
414
+ detail: "Token refresh request failed",
415
+ suggestions: [{ description: "Sign in again.", cmd: "axm login" }],
416
+ failure: registryAuthFailure(registryUrl, "Token exchange failed", error.cause),
417
+ }))));
418
+ });
419
+ const revokeToken = Effect.fn("AuthClient.revokeToken")(function* (token) {
420
+ yield* client
421
+ .AuthRevokeOAuthToken({
422
+ payload: { token, token_type_hint: "refresh_token" },
423
+ })
424
+ .pipe(Effect.catch((error) => {
425
+ const mapped = mapRegistryAuthError(registryUrl, "Token revocation failed", error);
426
+ const detail = mapped.detail ?? "Token revocation failed";
427
+ return Effect.logWarning(`${detail} Local credentials will still be cleared.`);
428
+ }));
429
+ });
430
+ const getMe = Effect.fn("AuthClient.getMe")(function* (accessToken) {
431
+ // Inject bearer token via a per-request HttpClient wrapper for getMe.
432
+ // The generated AuthGetMe operation uses GET /v1/auth/me with no payload,
433
+ // so we need to add the Authorization header via the httpClient.
434
+ const authedClient = makeGeneratedAuthClient(httpClient, registryUrl, accessToken);
435
+ const decoded = yield* authedClient
436
+ .AuthGetMe(undefined)
437
+ .pipe(Effect.mapError((error) => mapRegistryAuthError(registryUrl, "Could not read authenticated user", error)));
438
+ return {
439
+ userId: decoded.user.id,
440
+ userHandle: normalizeHandle(decoded.user.handle),
441
+ email: decoded.user.email ?? "",
442
+ tokenType: decoded.token.type,
443
+ scopes: decoded.token.scopes,
444
+ orgs: [],
445
+ };
446
+ });
447
+ const getWhoami = Effect.fn("AuthClient.getWhoami")(function* (accessToken) {
448
+ const authedClient = makeGeneratedAuthClient(httpClient, registryUrl, accessToken);
449
+ const decoded = yield* authedClient
450
+ .AuthGetWhoami(undefined)
451
+ .pipe(Effect.mapError((error) => mapRegistryAuthError(registryUrl, "Could not read authenticated identity", error)));
452
+ return {
453
+ handle: normalizeHandle(decoded.handle),
454
+ };
455
+ });
456
+ const createToken = Effect.fn("AuthClient.createToken")(function* (accessToken, params, options) {
457
+ const authedClient = makeGeneratedAuthClient(httpClient, registryUrl, accessToken);
458
+ const decoded = yield* authedClient
459
+ .TokensCreate({
460
+ ...(options?.stepUpRequestId === undefined
461
+ ? {}
462
+ : { params: { "x-axm-step-up-request": options.stepUpRequestId } }),
463
+ payload: {
464
+ name: params.name,
465
+ permissions: params.permissions,
466
+ expires_in: params.expiresIn,
467
+ },
468
+ })
469
+ .pipe(Effect.mapError((error) => {
470
+ const mapped = mapRegistryAuthError(registryUrl, "Could not create token", error);
471
+ const stepUp = readStepUpRequest(mapped);
472
+ return stepUp === null ? mapped : new StepUpRequired({ stepUp, failure: mapped });
473
+ }));
474
+ return {
475
+ id: decoded.id,
476
+ token: decoded.token,
477
+ name: decoded.name,
478
+ scopes: decoded.scopes,
479
+ permissions: decoded.permissions,
480
+ createdAt: decoded.created_at,
481
+ expiresAt: decoded.expires_at,
482
+ };
483
+ });
484
+ const listTokens = Effect.fn("AuthClient.listTokens")(function* (accessToken, params) {
485
+ const authedClient = makeGeneratedAuthClient(httpClient, registryUrl, accessToken);
486
+ const decoded = yield* authedClient
487
+ .TokensList({
488
+ params: {
489
+ ...(params?.limit === undefined ? {} : { limit: String(params.limit) }),
490
+ ...(params?.cursor === undefined ? {} : { cursor: params.cursor }),
491
+ },
492
+ })
493
+ .pipe(Effect.mapError((error) => mapRegistryAuthError(registryUrl, "Could not list tokens", error)));
494
+ return {
495
+ tokens: decoded.tokens.map((token) => ({
496
+ id: token.id,
497
+ name: token.name,
498
+ type: token.type,
499
+ scopes: token.scopes,
500
+ permissions: token.permissions,
501
+ createdAt: token.created_at,
502
+ expiresAt: token.expires_at,
503
+ lastUsedAt: token.last_used_at,
504
+ })),
505
+ hasMore: decoded.has_more,
506
+ cursor: decoded.cursor,
507
+ };
508
+ });
509
+ const waitForStepUpRequest = Effect.fn("AuthClient.waitForStepUpRequest")(function* (accessToken, statusUrl, intervalSeconds) {
510
+ const parsedStatusUrl = new URL(statusUrl);
511
+ const requestId = parsedStatusUrl.pathname.slice(parsedStatusUrl.pathname.lastIndexOf("/") + 1);
512
+ for (let attempt = 0; attempt < 300; attempt += 1) {
513
+ const authedClient = makeGeneratedAuthClient(httpClient, registryUrl, accessToken);
514
+ const result = yield* authedClient
515
+ .AuthGetStepUpRequest(requestId, undefined)
516
+ .pipe(Effect.map((response) => ({ kind: "status", response })), Effect.catch((error) => isRegistryClientError("AuthGetStepUpRequest429")(error) ||
517
+ (isHttpClientError(error) && error.response?.status === 429)
518
+ ? Effect.succeed({
519
+ kind: "rate_limited",
520
+ retryAfterSeconds: retryAfterSeconds(error.response?.headers["retry-after"], Math.max(1, intervalSeconds)),
521
+ })
522
+ : Effect.fail(mapRegistryAuthError(registryUrl, "Could not complete step-up", error))));
523
+ if (result.kind === "rate_limited") {
524
+ yield* Effect.sleep(Duration.seconds(result.retryAfterSeconds));
525
+ continue;
526
+ }
527
+ switch (result.response.status) {
528
+ case "verified":
529
+ return;
530
+ case "cancelled":
531
+ return yield* new RegistryAuthFailed({
532
+ category: "auth_denied",
533
+ detail: "The step-up request was cancelled.",
534
+ recover: "Rerun the command to start a new verification request.",
535
+ });
536
+ case "expired":
537
+ return yield* new RegistryAuthFailed({
538
+ category: "auth_expired",
539
+ detail: "The step-up request expired before verification completed.",
540
+ recover: "Rerun the command to start a new verification request.",
541
+ });
542
+ case "consumed":
543
+ return yield* new RegistryAuthFailed({
544
+ category: "conflict",
545
+ detail: "The step-up request has already been used.",
546
+ recover: "Rerun the command to start a new verification request.",
547
+ });
548
+ case "pending":
549
+ yield* Effect.sleep(Duration.seconds(Math.max(0, intervalSeconds)));
550
+ }
551
+ }
552
+ return yield* new RegistryAuthFailed({
553
+ category: "auth_expired",
554
+ detail: "The step-up request expired before verification completed.",
555
+ recover: "Rerun the command to start a new verification request.",
556
+ cause: { statusUrl },
557
+ });
558
+ });
559
+ const deleteToken = Effect.fn("AuthClient.deleteToken")(function* (accessToken, tokenId, options) {
560
+ const authedClient = makeGeneratedAuthClient(httpClient, registryUrl, accessToken, options?.stepUpRequestId);
561
+ yield* authedClient.TokensDelete(tokenId, undefined).pipe(Effect.mapError((error) => mapRegistryAuthError(registryUrl, "Could not revoke token", error)), Effect.mapError((error) => {
562
+ const stepUp = readStepUpRequest(error);
563
+ return stepUp === null ? error : new StepUpRequired({ stepUp, failure: error });
564
+ }));
565
+ });
566
+ return {
567
+ buildAuthorizeUrl,
568
+ getAuthorizationIssuer,
569
+ exchangePkceCode,
570
+ createPublishAuthorizationRequest,
571
+ exchangePublishAuthorizationCode,
572
+ initiateDeviceFlow,
573
+ pollDeviceToken,
574
+ refreshToken,
575
+ revokeToken,
576
+ getMe,
577
+ getWhoami,
578
+ createToken,
579
+ listTokens,
580
+ waitForStepUpRequest,
581
+ deleteToken,
582
+ };
583
+ }));
584
+ // -----------------------------------------------------------------------------
585
+ // Test layer factory
586
+ // -----------------------------------------------------------------------------
587
+ export const AuthClientTest = (overrides) => Layer.succeed(AuthClient, {
588
+ buildAuthorizeUrl: ({ redirectUri }) => `https://agentxm.ai/oauth/authorize?redirect_uri=${redirectUri}`,
589
+ getAuthorizationIssuer: () => "https://agentxm.ai",
590
+ exchangePkceCode: () => Effect.fail(new RegistryAuthFailed({
591
+ category: "auth",
592
+ detail: "Not implemented in test",
593
+ })),
594
+ createPublishAuthorizationRequest: () => Effect.fail(new RegistryAuthFailed({
595
+ category: "auth",
596
+ detail: "Not implemented in test",
597
+ })),
598
+ exchangePublishAuthorizationCode: () => Effect.fail(new RegistryAuthFailed({
599
+ category: "auth",
600
+ detail: "Not implemented in test",
601
+ })),
602
+ initiateDeviceFlow: () => Effect.fail(new RegistryAuthFailed({
603
+ category: "auth",
604
+ detail: "Not implemented in test",
605
+ })),
606
+ pollDeviceToken: () => Effect.fail(new RegistryAuthFailed({
607
+ category: "auth",
608
+ detail: "Not implemented in test",
609
+ })),
610
+ refreshToken: () => Effect.fail(new RegistryAuthFailed({
611
+ category: "auth",
612
+ detail: "Not implemented in test",
613
+ })),
614
+ revokeToken: () => Effect.void,
615
+ getMe: () => Effect.fail(new RegistryAuthFailed({
616
+ category: "auth",
617
+ detail: "Not implemented in test",
618
+ })),
619
+ getWhoami: () => Effect.fail(new RegistryAuthFailed({
620
+ category: "auth",
621
+ detail: "Not implemented in test",
622
+ })),
623
+ createToken: () => Effect.fail(new RegistryAuthFailed({
624
+ category: "auth",
625
+ detail: "Not implemented in test",
626
+ })),
627
+ listTokens: () => Effect.fail(new RegistryAuthFailed({
628
+ category: "auth",
629
+ detail: "Not implemented in test",
630
+ })),
631
+ waitForStepUpRequest: () => Effect.fail(new RegistryAuthFailed({
632
+ category: "auth",
633
+ detail: "Not implemented in test",
634
+ })),
635
+ deleteToken: () => Effect.void,
636
+ ...overrides,
637
+ });
638
+ //# sourceMappingURL=auth-client.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Auth middleware — HttpClient wrapping layer.
3
+ *
4
+ * Intercepts outgoing HTTP requests to inject Bearer tokens and handle
5
+ * automatic refresh on 401.
6
+ *
7
+ * Layer composition: wraps the base HttpClient so all downstream consumers
8
+ * get auth headers automatically for registry URLs.
9
+ *
10
+ * @experimental This API is unstable and may change without notice.
11
+ */
12
+ import * as HttpClient from "effect/unstable/http/HttpClient";
13
+ import * as Layer from "effect/Layer";
14
+ import { AuthClient } from "./auth-client.js";
15
+ import { CredentialStore } from "./credential-store.js";
16
+ import { RegistryUrl } from "@agentxm/registry-client";
17
+ /**
18
+ * Creates an auth middleware layer that wraps HttpClient with token injection
19
+ * and automatic refresh on 401.
20
+ *
21
+ * The `flagToken` parameter allows per-command --token flag injection.
22
+ */
23
+ export declare const makeAuthMiddlewareLive: (flagToken?: string) => Layer.Layer<HttpClient.HttpClient, never, AuthClient | HttpClient.HttpClient | RegistryUrl | CredentialStore>;
24
+ /**
25
+ * Default auth middleware layer (no --token flag).
26
+ */
27
+ export declare const AuthMiddlewareLive: Layer.Layer<HttpClient.HttpClient, never, AuthClient | HttpClient.HttpClient | RegistryUrl | CredentialStore>;
28
+ //# sourceMappingURL=auth-middleware.d.ts.map