@oxy.so/core 1.5.1 → 1.6.1

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 (130) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +72 -9
  3. package/dist/cjs/crypto/identityMove.js +53 -34
  4. package/dist/cjs/crypto/identityProof.js +28 -0
  5. package/dist/cjs/crypto/webIdentityCarrier.js +145 -82
  6. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  7. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  8. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  9. package/dist/cjs/i18n/locales/en-US.json +1 -0
  10. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  11. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  12. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  13. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  14. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  23. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  24. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  25. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  26. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  27. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  28. package/dist/cjs/index.js +22 -14
  29. package/dist/cjs/inference/OxyInferenceClient.js +62 -5
  30. package/dist/cjs/mixins/OxyServices.auth.js +129 -0
  31. package/dist/cjs/mixins/OxyServices.identity.js +11 -44
  32. package/dist/cjs/mixins/index.js +0 -2
  33. package/dist/cjs/server/index.js +10 -1
  34. package/dist/cjs/server/securityHeaders.js +9 -2
  35. package/dist/cjs/server/traffic.js +29 -2
  36. package/dist/cjs/server/workloadIdentity.js +170 -0
  37. package/dist/cjs/session/refresh.js +12 -0
  38. package/dist/esm/.tsbuildinfo +1 -1
  39. package/dist/esm/HttpService.js +72 -9
  40. package/dist/esm/crypto/identityMove.js +51 -33
  41. package/dist/esm/crypto/identityProof.js +24 -0
  42. package/dist/esm/crypto/webIdentityCarrier.js +137 -77
  43. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  44. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  45. package/dist/esm/i18n/locales/de-DE.json +1 -0
  46. package/dist/esm/i18n/locales/en-US.json +1 -0
  47. package/dist/esm/i18n/locales/es-ES.json +1 -0
  48. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  49. package/dist/esm/i18n/locales/it-IT.json +1 -0
  50. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  51. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  52. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  53. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  54. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  55. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  56. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  57. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  58. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  59. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  60. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  61. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  62. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  63. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  64. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  65. package/dist/esm/index.js +5 -4
  66. package/dist/esm/inference/OxyInferenceClient.js +63 -6
  67. package/dist/esm/mixins/OxyServices.auth.js +96 -0
  68. package/dist/esm/mixins/OxyServices.identity.js +11 -44
  69. package/dist/esm/mixins/index.js +0 -2
  70. package/dist/esm/server/index.js +7 -0
  71. package/dist/esm/server/securityHeaders.js +9 -2
  72. package/dist/esm/server/traffic.js +29 -2
  73. package/dist/esm/server/workloadIdentity.js +166 -0
  74. package/dist/esm/session/refresh.js +12 -0
  75. package/dist/types/.tsbuildinfo +1 -1
  76. package/dist/types/HttpService.d.ts +24 -0
  77. package/dist/types/crypto/identityMove.d.ts +48 -32
  78. package/dist/types/crypto/identityProof.d.ts +16 -0
  79. package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
  80. package/dist/types/index.d.ts +7 -6
  81. package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
  82. package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
  83. package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
  84. package/dist/types/mixins/index.d.ts +1 -2
  85. package/dist/types/server/index.d.ts +8 -0
  86. package/dist/types/server/securityHeaders.d.ts +8 -0
  87. package/dist/types/server/workloadIdentity.d.ts +32 -0
  88. package/package.json +2 -2
  89. package/src/HttpService.ts +74 -9
  90. package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
  91. package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
  92. package/src/crypto/__tests__/identityMove.test.ts +93 -51
  93. package/src/crypto/__tests__/identityProof.test.ts +81 -0
  94. package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
  95. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
  96. package/src/crypto/identityMove.ts +77 -43
  97. package/src/crypto/identityProof.ts +36 -0
  98. package/src/crypto/webIdentityCarrier.ts +185 -117
  99. package/src/i18n/locales/ar-SA.json +1 -0
  100. package/src/i18n/locales/ca-ES.json +1 -0
  101. package/src/i18n/locales/de-DE.json +1 -0
  102. package/src/i18n/locales/en-US.json +1 -0
  103. package/src/i18n/locales/es-ES.json +1 -0
  104. package/src/i18n/locales/fr-FR.json +1 -0
  105. package/src/i18n/locales/it-IT.json +1 -0
  106. package/src/i18n/locales/ja-JP.json +1 -0
  107. package/src/i18n/locales/ko-KR.json +1 -0
  108. package/src/i18n/locales/pt-PT.json +1 -0
  109. package/src/i18n/locales/zh-CN.json +1 -0
  110. package/src/index.ts +26 -15
  111. package/src/inference/OxyInferenceClient.ts +84 -7
  112. package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
  113. package/src/mixins/OxyServices.auth.ts +100 -1
  114. package/src/mixins/OxyServices.identity.ts +17 -67
  115. package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
  116. package/src/mixins/index.ts +0 -3
  117. package/src/server/__tests__/securityHeaders.test.ts +15 -0
  118. package/src/server/__tests__/traffic.test.ts +132 -1
  119. package/src/server/__tests__/workloadIdentity.test.ts +128 -0
  120. package/src/server/index.ts +9 -0
  121. package/src/server/securityHeaders.ts +19 -6
  122. package/src/server/traffic.ts +31 -1
  123. package/src/server/workloadIdentity.ts +207 -0
  124. package/src/session/__tests__/refresh.test.ts +53 -3
  125. package/src/session/refresh.ts +12 -0
  126. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
  127. package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
  128. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
  129. package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
  130. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
@@ -38,7 +38,6 @@ const OxyServices_nodes_1 = require("./OxyServices.nodes");
38
38
  const OxyServices_followGraph_1 = require("./OxyServices.followGraph");
39
39
  const OxyServices_inference_1 = require("./OxyServices.inference");
40
40
  const OxyServices_deviceBoot_1 = require("./OxyServices.deviceBoot");
41
- const OxyServices_deviceTransfer_1 = require("./OxyServices.deviceTransfer");
42
41
  /**
43
42
  * Mixin pipeline - applied in order from first to last.
44
43
  *
@@ -110,7 +109,6 @@ const MIXIN_PIPELINE = [
110
109
  OxyServices_deviceBoot_1.OxyServicesDeviceBootMixin,
111
110
  // Device-to-device identity transfer ("add a device"): E2E-encrypted key
112
111
  // clone over a short-lived relay (b3 Feature 2).
113
- OxyServices_deviceTransfer_1.OxyServicesDeviceTransferMixin,
114
112
  // Utility (last, can use all above)
115
113
  OxyServices_utility_1.OxyServicesUtilityMixin,
116
114
  ];
@@ -17,7 +17,7 @@
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.signOxyRequesterAssertion = exports.readOxyRequesterAssertionKeyId = exports.oxyRequesterAssertionClaimsSchema = exports.OxyRequesterAssertionError = exports.OXY_REQUESTER_ASSERTION_TYPE = exports.OXY_REQUESTER_ASSERTION_MAX_TTL_SECONDS = exports.OXY_REQUESTER_ASSERTION_HEADER = exports.OXY_REQUESTER_ASSERTION_DEFAULT_ISSUER = exports.createOxyRequesterAssertionAuth = exports.createOxyJwksKeyResolver = exports.verifyCapabilityTicket = exports.readCapabilityAuthorization = exports.issueCapabilityTicket = exports.inputSatisfiesCapabilityLimits = exports.createCapabilityTicketMiddleware = exports.CapabilityTicketError = exports.OXY_CSP_BASELINE = exports.inlineScriptCspHash = exports.formatOxyCspPolicy = exports.extractInlineScripts = exports.cspSourcesFor = exports.createOxySecurityHeaders = exports.buildOxyPagesHeaders = exports.buildOxyCspDirectives = exports.createOxyCors = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.createOxyRateLimit = exports.OXY_SERVICE_ENVIRONMENTS = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getRequiredOxyBillingPrincipal = exports.getOxyUserId = exports.getOxyRequestAttribution = exports.getOxyDelegatedUserId = exports.getOxyBillingPrincipal = exports.getOxyAuthRefusal = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
20
- exports.withoutNodeHttpObservation = exports.observeNodeHttp = exports.createEcosystemTraffic = exports.isOfficialWebOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.verifySecret = exports.verifyOxyRequesterAssertion = void 0;
20
+ exports.requestWorkloadServiceToken = exports.canAttestWorkloadIdentity = exports.withoutNodeHttpObservation = exports.observeNodeHttp = exports.createEcosystemTraffic = exports.isOfficialWebOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.verifySecret = exports.verifyOxyRequesterAssertion = void 0;
21
21
  var auth_1 = require("./auth");
22
22
  Object.defineProperty(exports, "createOptionalOxyAuth", { enumerable: true, get: function () { return auth_1.createOptionalOxyAuth; } });
23
23
  Object.defineProperty(exports, "createOxyAuthMiddleware", { enumerable: true, get: function () { return auth_1.createOxyAuthMiddleware; } });
@@ -116,3 +116,12 @@ Object.defineProperty(exports, "createEcosystemTraffic", { enumerable: true, get
116
116
  var trafficNodeHttp_1 = require("./trafficNodeHttp");
117
117
  Object.defineProperty(exports, "observeNodeHttp", { enumerable: true, get: function () { return trafficNodeHttp_1.observeNodeHttp; } });
118
118
  Object.defineProperty(exports, "withoutNodeHttpObservation", { enumerable: true, get: function () { return trafficNodeHttp_1.withoutNodeHttpObservation; } });
119
+ /**
120
+ * Workload identity (ADR 0026): a first-party service asks Oxy for a service
121
+ * token by proving what it IS, with no api key and no secret. Server-only, and
122
+ * exported from this subpath rather than the root barrel because it reaches the
123
+ * container credentials endpoint.
124
+ */
125
+ var workloadIdentity_1 = require("./workloadIdentity");
126
+ Object.defineProperty(exports, "canAttestWorkloadIdentity", { enumerable: true, get: function () { return workloadIdentity_1.canAttestWorkloadIdentity; } });
127
+ Object.defineProperty(exports, "requestWorkloadServiceToken", { enumerable: true, get: function () { return workloadIdentity_1.requestWorkloadServiceToken; } });
@@ -333,9 +333,16 @@ function buildOxyPagesHeaders(options = {}) {
333
333
  if (hashes.length > MAX_INLINE_SCRIPT_HASHES) {
334
334
  throw new RangeError(`Oxy CSP: ${hashes.length} distinct inline scripts in the built HTML exceeds the ${MAX_INLINE_SCRIPT_HASHES}-hash ceiling. A per-route inline data block (e.g. an Expo Router loader) is the likely cause; allow it deliberately rather than by raising this.`);
335
335
  }
336
- const csp = formatOxyCspPolicy(buildOxyCspDirectives(hashes.length === 0
336
+ const directives = buildOxyCspDirectives(hashes.length === 0
337
337
  ? options.csp
338
- : { ...options.csp, scriptSrc: [...(options.csp?.scriptSrc ?? []), ...hashes] }));
338
+ : { ...options.csp, scriptSrc: [...(options.csp?.scriptSrc ?? []), ...hashes] });
339
+ if (options.sensitive) {
340
+ const beacon = new Set([CLOUDFLARE_INSIGHTS_SCRIPT_ORIGIN, CLOUDFLARE_INSIGHTS_REPORT_ORIGIN]);
341
+ for (const name of ['script-src', 'connect-src']) {
342
+ directives[name] = (directives[name] ?? []).filter((source) => !beacon.has(source));
343
+ }
344
+ }
345
+ const csp = formatOxyCspPolicy(directives);
339
346
  const lines = [
340
347
  '/*',
341
348
  ` Content-Security-Policy: ${csp}`,
@@ -5,6 +5,12 @@ const trafficNodeHttp_1 = require("./trafficNodeHttp");
5
5
  const socket_1 = require("@oxy.so/telemetry/socket");
6
6
  const node_crypto_1 = require("node:crypto");
7
7
  const OxyServices_1 = require("../OxyServices");
8
+ // Static, unlike the lazy import in `mixins/OxyServices.auth.ts`: that module is
9
+ // reachable from the root barrel, which ships to React Native, so pulling
10
+ // `node:crypto` in eagerly there would cost every mobile bundle. This file is
11
+ // server-only and the server barrel already exports `./workloadIdentity`, so
12
+ // there is nothing here for a phone to load.
13
+ const workloadIdentity_1 = require("./workloadIdentity");
8
14
  const collector_1 = require("@oxy.so/telemetry/collector");
9
15
  /** Install once per service process, before registering HTTP routes. */
10
16
  function createEcosystemTraffic(options) {
@@ -17,9 +23,30 @@ function createEcosystemTraffic(options) {
17
23
  }
18
24
  const apiKey = activityKey || process.env.OXY_SERVICE_API_KEY;
19
25
  const apiSecret = activitySecret || process.env.OXY_SERVICE_API_SECRET;
20
- if (!options.credential && (!apiKey || !apiSecret))
21
- throw new Error('Ecosystem activity requires OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET');
26
+ /**
27
+ * Having no key pair is a legitimate configuration now — but only where this
28
+ * process can prove what it IS (ADR 0026).
29
+ *
30
+ * This used to throw on a missing pair, full stop. Every service in the fleet
31
+ * boots with `OXY_ECOSYSTEM_ACTIVITY_ENABLED=true`, so deleting
32
+ * `OXY_SERVICE_API_KEY` and `OXY_SERVICE_API_SECRET` from a task definition —
33
+ * which is the entire migration ADR 0026 asks for — killed the process at
34
+ * boot. The ECS circuit breaker then rolled the deploy back and reported the
35
+ * service stable, so the operator saw a rollback with nothing naming its
36
+ * cause. That is what one line cost, multiplied by fourteen services holding
37
+ * a secret they no longer need.
38
+ *
39
+ * What is still refused: nothing to authenticate with AND nothing to attest.
40
+ * That is a laptop or a CI box, where the container credentials endpoint is
41
+ * not going to turn up later, and the honest answer is to say so at boot
42
+ * rather than to fail on the first heartbeat.
43
+ */
44
+ if (!options.credential && (!apiKey || !apiSecret) && !(0, workloadIdentity_1.canAttestWorkloadIdentity)()) {
45
+ throw new Error('Ecosystem activity needs one of: an OXY_ACTIVITY_API_KEY and OXY_ACTIVITY_API_SECRET pair, an OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET pair, or a workload identity this process can attest (ADR 0026).');
46
+ }
22
47
  const oxy = new OxyServices_1.OxyServices({ baseURL });
48
+ // Left unconfigured when there is no pair on purpose: `getServiceToken()`
49
+ // attests instead of being handed half a credential to fail on.
23
50
  if (apiKey && apiSecret)
24
51
  oxy.configureServiceAuth(apiKey, apiSecret);
25
52
  const credential = options.credential ?? (() => oxy.getServiceToken());
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.canAttestWorkloadIdentity = canAttestWorkloadIdentity;
4
+ exports.requestWorkloadServiceToken = requestWorkloadServiceToken;
5
+ const node_crypto_1 = require("node:crypto");
6
+ /**
7
+ * Asking Oxy for a service token by proving what this process IS (ADR 0026).
8
+ *
9
+ * The other half of the credential-free path: `packages/api` verifies the proof,
10
+ * this builds one. A first-party service that calls
11
+ * {@link requestWorkloadServiceToken} needs no api key, no secret and no entry in
12
+ * a parameter store — only to be running as itself.
13
+ *
14
+ * ## Why this signs SigV4 by hand
15
+ *
16
+ * The attestation is a signed `GetCallerIdentity` request that is never sent to
17
+ * AWS by us; Oxy replays it. Producing one needs credentials and a signature,
18
+ * and the obvious way to get both is the AWS SDK — which this package must not
19
+ * depend on. `@oxy.so/core` ships to React Native and Expo, where several
20
+ * megabytes of AWS client for a call that only ever happens on a server is not a
21
+ * trade worth making, and the package rules keep optional heavyweights out of
22
+ * the root barrel for exactly this reason.
23
+ *
24
+ * So: credentials come from the container credentials endpoint ECS already
25
+ * exposes to every task, and the signature is ~40 lines of HMAC out of
26
+ * `node:crypto`. No dependency, and nothing here is AWS-specific above
27
+ * {@link awsContainerAttestation} — a second provider is another function with
28
+ * the same shape.
29
+ *
30
+ * ## What this never does
31
+ *
32
+ * It never sends the signed request to AWS. It never logs a credential, a
33
+ * signature or a token. It caches the TOKEN (short-lived, from Oxy) and never
34
+ * the AWS credentials, which the endpoint hands out fresh anyway.
35
+ */
36
+ /** The header Oxy's verifier requires inside the signature. Must match `ATTESTATION_NONCE_HEADER`. */
37
+ const NONCE_HEADER = 'x-oxy-attestation-nonce';
38
+ const STS_HOST = 'sts.amazonaws.com';
39
+ const STS_REGION = 'us-east-1';
40
+ const STS_BODY = 'Action=GetCallerIdentity&Version=2011-06-15';
41
+ /**
42
+ * Whether this process can attest at all.
43
+ *
44
+ * The relative URI is set by ECS on every task and by nothing else, so its
45
+ * presence is the honest test for "we can prove what we are here". A local
46
+ * checkout has no attestation to offer and must fall back to a credential.
47
+ */
48
+ function canAttestWorkloadIdentity(env = process.env) {
49
+ return Boolean(env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || env.AWS_CONTAINER_CREDENTIALS_FULL_URI);
50
+ }
51
+ async function containerCredentials(fetchImpl) {
52
+ const relative = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
53
+ const url = relative
54
+ ? `http://169.254.170.2${relative}`
55
+ : process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
56
+ if (!url) {
57
+ throw new Error('No container credentials endpoint: this process cannot attest its identity.');
58
+ }
59
+ const response = await fetchImpl(url, {
60
+ headers: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN
61
+ ? { authorization: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN }
62
+ : {},
63
+ });
64
+ if (!response.ok) {
65
+ throw new Error(`The container credentials endpoint answered ${response.status}.`);
66
+ }
67
+ const credentials = (await response.json());
68
+ if (!credentials.AccessKeyId || !credentials.SecretAccessKey) {
69
+ throw new Error('The container credentials endpoint answered without credentials.');
70
+ }
71
+ return credentials;
72
+ }
73
+ function hmac(key, data) {
74
+ return (0, node_crypto_1.createHmac)('sha256', key).update(data, 'utf8').digest();
75
+ }
76
+ function sha256Hex(value) {
77
+ return (0, node_crypto_1.createHash)('sha256').update(value, 'utf8').digest('hex');
78
+ }
79
+ /**
80
+ * Signs `GetCallerIdentity` with SigV4, binding the nonce into the signature.
81
+ *
82
+ * `SignedHeaders` includes the nonce header deliberately: Oxy refuses an
83
+ * attestation whose signature does not cover it, because a nonce the signature
84
+ * does not cover can be swapped by whoever captured the attestation.
85
+ */
86
+ function signGetCallerIdentity(credentials, nonce, now) {
87
+ const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
88
+ const dateStamp = amzDate.slice(0, 8);
89
+ const headers = {
90
+ host: STS_HOST,
91
+ 'x-amz-date': amzDate,
92
+ [NONCE_HEADER]: nonce,
93
+ };
94
+ if (credentials.Token)
95
+ headers['x-amz-security-token'] = credentials.Token;
96
+ const signedHeaderNames = Object.keys(headers).sort();
97
+ const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]}\n`).join('');
98
+ const signedHeaders = signedHeaderNames.join(';');
99
+ const canonicalRequest = [
100
+ 'POST',
101
+ '/',
102
+ '',
103
+ canonicalHeaders,
104
+ signedHeaders,
105
+ sha256Hex(STS_BODY),
106
+ ].join('\n');
107
+ const scope = `${dateStamp}/${STS_REGION}/sts/aws4_request`;
108
+ const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n');
109
+ const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.SecretAccessKey}`, dateStamp), STS_REGION), 'sts'), 'aws4_request');
110
+ const signature = (0, node_crypto_1.createHmac)('sha256', signingKey).update(stringToSign, 'utf8').digest('hex');
111
+ return {
112
+ ...headers,
113
+ authorization: `AWS4-HMAC-SHA256 Credential=${credentials.AccessKeyId}/${scope}, ` +
114
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`,
115
+ };
116
+ }
117
+ /** The AWS attestation: signed headers, and nothing else. */
118
+ async function awsContainerAttestation(fetchImpl, nonce) {
119
+ const credentials = await containerCredentials(fetchImpl);
120
+ return { headers: signGetCallerIdentity(credentials, nonce, new Date()) };
121
+ }
122
+ /**
123
+ * Exchanges this process's identity for an Oxy service token.
124
+ *
125
+ * Two round trips by design (see ADR 0026): a nonce, then an attestation that
126
+ * signs it. The caller caches the token — it lives an hour — rather than doing
127
+ * this per request.
128
+ *
129
+ * Failures are thrown, never swallowed into an empty token: a caller that
130
+ * cannot prove what it is must not go on to make an unauthenticated request and
131
+ * discover the problem as a 401 somewhere else.
132
+ */
133
+ async function requestWorkloadServiceToken(options) {
134
+ const fetchImpl = options.fetch ?? fetch;
135
+ const base = options.baseUrl.replace(/\/+$/, '');
136
+ const challengeResponse = await fetchImpl(`${base}/auth/service-token/workload/challenge`, {
137
+ method: 'POST',
138
+ headers: { 'content-type': 'application/json' },
139
+ });
140
+ if (!challengeResponse.ok) {
141
+ throw new Error(`Oxy refused to issue a workload challenge (${challengeResponse.status}).`);
142
+ }
143
+ const challenge = unwrap(await challengeResponse.json());
144
+ if (!challenge?.nonce) {
145
+ throw new Error('Oxy issued a workload challenge with no nonce.');
146
+ }
147
+ const attestation = await awsContainerAttestation(fetchImpl, challenge.nonce);
148
+ const tokenResponse = await fetchImpl(`${base}/auth/service-token/workload`, {
149
+ method: 'POST',
150
+ headers: { 'content-type': 'application/json' },
151
+ body: JSON.stringify({ provider: 'aws-iam', nonce: challenge.nonce, attestation }),
152
+ });
153
+ if (!tokenResponse.ok) {
154
+ // The body carries a bounded `reason`; the token never travels on a failure,
155
+ // so there is nothing here that must not be seen.
156
+ throw new Error(`Oxy refused the workload attestation (${tokenResponse.status}).`);
157
+ }
158
+ const granted = unwrap(await tokenResponse.json());
159
+ if (!granted?.token || typeof granted.expiresIn !== 'number') {
160
+ throw new Error('Oxy answered the attestation without a usable token.');
161
+ }
162
+ return { token: granted.token, expiresIn: granted.expiresIn, appName: granted.appName ?? '' };
163
+ }
164
+ /** Oxy wraps successful bodies in `{ data }`; older routes answer flat. */
165
+ function unwrap(body) {
166
+ if (typeof body !== 'object' || body === null)
167
+ return null;
168
+ const envelope = body;
169
+ return (typeof envelope.data === 'object' && envelope.data !== null ? envelope.data : body);
170
+ }
@@ -80,6 +80,18 @@ async function refreshDeviceSecretArm(deps) {
80
80
  : await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
81
81
  }
82
82
  catch (error) {
83
+ // 429 keeps the credential like any other transient failure — the secret
84
+ // was never judged — but it must NOT retry on the transient cadence. The
85
+ // request-driven lanes (request-time preflight, 401 retry) reattempt every
86
+ // second once the access token is expired, which is 60 mints/min against a
87
+ // 30/min server budget: the client's own retries then hold the limiter
88
+ // tripped and the session cannot recover while the app keeps making
89
+ // requests. Telling HttpService lengthens the next cooldown to one attempt
90
+ // per limiter window, which lets the budget drain and the session heal.
91
+ if ((0, errorUtils_1.extractErrorStatus)(error) === 429) {
92
+ oxy.httpService.noteRefreshRateLimited();
93
+ return { status: 'transient' };
94
+ }
83
95
  if ((0, errorUtils_1.extractErrorStatus)(error) === 401) {
84
96
  // Structural read (not `instanceof Error`): the thrown value can be a
85
97
  // plain ApiError-shaped object or come from another realm.