@oxy.so/core 1.5.1 → 1.6.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 (126) 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/workloadIdentity.js +170 -0
  36. package/dist/cjs/session/refresh.js +12 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/HttpService.js +72 -9
  39. package/dist/esm/crypto/identityMove.js +51 -33
  40. package/dist/esm/crypto/identityProof.js +24 -0
  41. package/dist/esm/crypto/webIdentityCarrier.js +137 -77
  42. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  43. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  44. package/dist/esm/i18n/locales/de-DE.json +1 -0
  45. package/dist/esm/i18n/locales/en-US.json +1 -0
  46. package/dist/esm/i18n/locales/es-ES.json +1 -0
  47. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  48. package/dist/esm/i18n/locales/it-IT.json +1 -0
  49. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  50. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  51. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  52. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  53. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  54. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  55. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  56. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  57. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  58. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  59. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  60. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  61. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  62. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  63. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  64. package/dist/esm/index.js +5 -4
  65. package/dist/esm/inference/OxyInferenceClient.js +63 -6
  66. package/dist/esm/mixins/OxyServices.auth.js +96 -0
  67. package/dist/esm/mixins/OxyServices.identity.js +11 -44
  68. package/dist/esm/mixins/index.js +0 -2
  69. package/dist/esm/server/index.js +7 -0
  70. package/dist/esm/server/securityHeaders.js +9 -2
  71. package/dist/esm/server/workloadIdentity.js +166 -0
  72. package/dist/esm/session/refresh.js +12 -0
  73. package/dist/types/.tsbuildinfo +1 -1
  74. package/dist/types/HttpService.d.ts +24 -0
  75. package/dist/types/crypto/identityMove.d.ts +48 -32
  76. package/dist/types/crypto/identityProof.d.ts +16 -0
  77. package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
  78. package/dist/types/index.d.ts +7 -6
  79. package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
  80. package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
  81. package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
  82. package/dist/types/mixins/index.d.ts +1 -2
  83. package/dist/types/server/index.d.ts +8 -0
  84. package/dist/types/server/securityHeaders.d.ts +8 -0
  85. package/dist/types/server/workloadIdentity.d.ts +32 -0
  86. package/package.json +1 -1
  87. package/src/HttpService.ts +74 -9
  88. package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
  89. package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
  90. package/src/crypto/__tests__/identityMove.test.ts +93 -51
  91. package/src/crypto/__tests__/identityProof.test.ts +81 -0
  92. package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
  93. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
  94. package/src/crypto/identityMove.ts +77 -43
  95. package/src/crypto/identityProof.ts +36 -0
  96. package/src/crypto/webIdentityCarrier.ts +185 -117
  97. package/src/i18n/locales/ar-SA.json +1 -0
  98. package/src/i18n/locales/ca-ES.json +1 -0
  99. package/src/i18n/locales/de-DE.json +1 -0
  100. package/src/i18n/locales/en-US.json +1 -0
  101. package/src/i18n/locales/es-ES.json +1 -0
  102. package/src/i18n/locales/fr-FR.json +1 -0
  103. package/src/i18n/locales/it-IT.json +1 -0
  104. package/src/i18n/locales/ja-JP.json +1 -0
  105. package/src/i18n/locales/ko-KR.json +1 -0
  106. package/src/i18n/locales/pt-PT.json +1 -0
  107. package/src/i18n/locales/zh-CN.json +1 -0
  108. package/src/index.ts +26 -15
  109. package/src/inference/OxyInferenceClient.ts +84 -7
  110. package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
  111. package/src/mixins/OxyServices.auth.ts +100 -1
  112. package/src/mixins/OxyServices.identity.ts +17 -67
  113. package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
  114. package/src/mixins/index.ts +0 -3
  115. package/src/server/__tests__/securityHeaders.test.ts +15 -0
  116. package/src/server/__tests__/workloadIdentity.test.ts +128 -0
  117. package/src/server/index.ts +9 -0
  118. package/src/server/securityHeaders.ts +19 -6
  119. package/src/server/workloadIdentity.ts +207 -0
  120. package/src/session/__tests__/refresh.test.ts +53 -3
  121. package/src/session/refresh.ts +12 -0
  122. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
  123. package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
  124. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
  125. package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
  126. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
@@ -155,6 +155,14 @@ export declare function inlineScriptCspHash(source: string): string;
155
155
  export interface OxyPagesHeadersOptions {
156
156
  /** Per-app additions merged into {@link OXY_CSP_BASELINE}. */
157
157
  csp?: OxyCspExtensions;
158
+ /**
159
+ * An origin where people authenticate, consent or recover (`auth.oxy.so`,
160
+ * ADR 0024 D1). The Cloudflare Insights beacon — injected by the edge, not by
161
+ * the app — is REMOVED from `script-src` and `connect-src`, so third-party
162
+ * measurement cannot run there even when the zone has Web Analytics enabled.
163
+ * A structural block in the policy the page is served with, not a build flag.
164
+ */
165
+ sensitive?: boolean;
158
166
  /**
159
167
  * Emit `Strict-Transport-Security` (default `true`). Cloudflare Pages serves
160
168
  * HTTPS only, so static deploys should keep this on.
@@ -0,0 +1,32 @@
1
+ export interface WorkloadServiceTokenOptions {
2
+ /** Oxy's base URL, e.g. `https://api.oxy.so`. */
3
+ baseUrl: string;
4
+ /** Injected in tests; defaults to the global. */
5
+ fetch?: typeof fetch;
6
+ }
7
+ export interface WorkloadServiceToken {
8
+ token: string;
9
+ /** Seconds from now, as Oxy reported it. */
10
+ expiresIn: number;
11
+ appName: string;
12
+ }
13
+ /**
14
+ * Whether this process can attest at all.
15
+ *
16
+ * The relative URI is set by ECS on every task and by nothing else, so its
17
+ * presence is the honest test for "we can prove what we are here". A local
18
+ * checkout has no attestation to offer and must fall back to a credential.
19
+ */
20
+ export declare function canAttestWorkloadIdentity(env?: NodeJS.ProcessEnv): boolean;
21
+ /**
22
+ * Exchanges this process's identity for an Oxy service token.
23
+ *
24
+ * Two round trips by design (see ADR 0026): a nonce, then an attestation that
25
+ * signs it. The caller caches the token — it lives an hour — rather than doing
26
+ * this per request.
27
+ *
28
+ * Failures are thrown, never swallowed into an empty token: a caller that
29
+ * cannot prove what it is must not go on to make an unauthenticated request and
30
+ * discover the problem as a 401 somewhere else.
31
+ */
32
+ export declare function requestWorkloadServiceToken(options: WorkloadServiceTokenOptions): Promise<WorkloadServiceToken>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -186,6 +186,34 @@ const TOKEN_REFRESH_COOLDOWN_MS = 15000;
186
186
  */
187
187
  const EXPIRED_TOKEN_REFRESH_COOLDOWN_MS = 1000;
188
188
 
189
+ /**
190
+ * Cooldown (ms) applied after the refresh endpoint answered 429 Too Many
191
+ * Requests. Overrides BOTH cooldowns above, because neither of them is about
192
+ * this failure.
193
+ *
194
+ * The other two ask "is the current token still usable?" and answer a server
195
+ * that is unreachable or erroring. A 429 is the opposite situation: the server
196
+ * is perfectly reachable and is telling this client, specifically, that it is
197
+ * asking too often. `EXPIRED_TOKEN_REFRESH_COOLDOWN_MS` then becomes actively
198
+ * harmful — an expired token drives the request-time preflight and the 401
199
+ * retry lanes at one mint per second (60/min) against a mint budget of 30/min,
200
+ * so the client's own retries hold the limiter tripped and it can never fall
201
+ * back under the ceiling. The limit stops being self-clearing and becomes
202
+ * self-sustaining for as long as the app keeps issuing requests.
203
+ *
204
+ * Sized to the mint limiter's own window (60s, `packages/api/src/routes/sessionDevice.ts`)
205
+ * so ONE attempt lands per window: enough to discover the moment the budget has
206
+ * drained, never enough to consume it. The per-device lockout answers 429 for
207
+ * far longer (15 min), and that is fine — one probe a minute against it is
208
+ * negligible traffic and the session recovers the instant the lockout lifts.
209
+ *
210
+ * NOT routed through the scheduler's exponential backoff: that backoff is
211
+ * effect-local to `startTokenRefreshScheduler` and governs only the scheduled
212
+ * lane, while the lanes that actually produce the storm here (preflight, 401
213
+ * retry) are request-driven and are throttled solely by this cooldown.
214
+ */
215
+ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60_000;
216
+
189
217
  /**
190
218
  * Lead time (seconds) before access-token expiry at which a preflight refresh
191
219
  * is triggered. A token within this window of `exp` is treated as effectively
@@ -293,6 +321,17 @@ export class HttpService {
293
321
  * fixed deadline that could not shrink once the token expired mid-cooldown.
294
322
  */
295
323
  private lastRefreshFailureAt = 0;
324
+ /**
325
+ * Whether the last refresh failure was the server answering 429. Selects
326
+ * {@link RATE_LIMITED_REFRESH_COOLDOWN_MS} over the two token-state cooldowns
327
+ * for the NEXT attempt, and is cleared by any success or any other failure.
328
+ *
329
+ * A boolean beside {@link lastRefreshFailureAt} rather than a stored deadline,
330
+ * for the same reason that field gives: the cooldown stays a function of the
331
+ * current state, so it can still shorten if the situation changes underneath
332
+ * it, and there is exactly one timestamp to reason about.
333
+ */
334
+ private lastRefreshWasRateLimited = false;
296
335
  private authRefreshHandler: AuthRefreshHandler | null = null;
297
336
  private accessTokenProvider: AccessTokenProvider | null = null;
298
337
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
@@ -1156,20 +1195,29 @@ export class HttpService {
1156
1195
  return null;
1157
1196
  }
1158
1197
 
1159
- // Post-failure cooldown. A genuinely EXPIRED current token uses a much
1160
- // shorter cooldown than a still-valid (proactive, near-expiry) one: an
1161
- // expired token is unusable, so re-mint as soon as the endpoint is reachable
1162
- // again rather than waiting out the full window while requests carry a stale
1163
- // bearer. Both cooldowns are measured from the last failure, so the moment a
1164
- // still-valid token crosses `exp` mid-cooldown the shorter window applies.
1165
- const cooldownMs = this.isAccessTokenExpired()
1166
- ? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
1167
- : TOKEN_REFRESH_COOLDOWN_MS;
1198
+ // Post-failure cooldown. A 429 names its own window regardless of token
1199
+ // state — the server is reachable and is rationing this client, so retrying
1200
+ // at the expired-token rate would spend the very budget being waited on.
1201
+ // Otherwise a genuinely EXPIRED current token uses a much shorter cooldown
1202
+ // than a still-valid (proactive, near-expiry) one: an expired token is
1203
+ // unusable, so re-mint as soon as the endpoint is reachable again rather
1204
+ // than waiting out the full window while requests carry a stale bearer. All
1205
+ // three are measured from the last failure, so the moment a still-valid
1206
+ // token crosses `exp` mid-cooldown the shorter window applies.
1207
+ const cooldownMs = this.lastRefreshWasRateLimited
1208
+ ? RATE_LIMITED_REFRESH_COOLDOWN_MS
1209
+ : this.isAccessTokenExpired()
1210
+ ? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
1211
+ : TOKEN_REFRESH_COOLDOWN_MS;
1168
1212
  if (Date.now() - this.lastRefreshFailureAt < cooldownMs) {
1169
1213
  return null;
1170
1214
  }
1171
1215
 
1172
1216
  if (!this.tokenRefreshPromise) {
1217
+ // Cleared before the attempt, never after it: the handler reports a 429
1218
+ // by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
1219
+ // on the way out would discard the flag it just set.
1220
+ this.lastRefreshWasRateLimited = false;
1173
1221
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
1174
1222
  .then((newToken) => {
1175
1223
  if (!newToken) {
@@ -1183,6 +1231,7 @@ export class HttpService {
1183
1231
  // A success clears the failure timestamp so the next refresh is never
1184
1232
  // throttled by a stale cooldown.
1185
1233
  this.lastRefreshFailureAt = 0;
1234
+ this.lastRefreshWasRateLimited = false;
1186
1235
  this.logger.debug('Token refreshed via the auth refresh handler');
1187
1236
  return newToken;
1188
1237
  })
@@ -1199,6 +1248,22 @@ export class HttpService {
1199
1248
  return this.tokenRefreshPromise;
1200
1249
  }
1201
1250
 
1251
+ /**
1252
+ * Report that the refresh/mint endpoint answered 429 Too Many Requests, so the
1253
+ * next attempt waits {@link RATE_LIMITED_REFRESH_COOLDOWN_MS} instead of one of
1254
+ * the token-state cooldowns.
1255
+ *
1256
+ * Called by the refresh handler (`refreshDeviceSecretArm`) from inside the
1257
+ * `authRefreshHandler` call, because the handler's `Promise<string | null>`
1258
+ * contract cannot carry WHY a refresh failed and 429 is the one failure whose
1259
+ * correct retry interval is set by the server rather than by the token.
1260
+ *
1261
+ * Public because the handler lives in `session/refresh.ts`, not on this class.
1262
+ */
1263
+ noteRefreshRateLimited(): void {
1264
+ this.lastRefreshWasRateLimited = true;
1265
+ }
1266
+
1202
1267
  /**
1203
1268
  * Whether the CURRENT stored access token is already past its `exp`. Drives
1204
1269
  * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The post-429 refresh cooldown.
3
+ *
4
+ * `POST /session/device/token` is budgeted at 30 mints/minute
5
+ * (`packages/api/src/routes/sessionDevice.ts`). Once the access token is
6
+ * EXPIRED, the request-driven refresh lanes — the request-time preflight in
7
+ * `getAuthHeader` and the 401 retry — reattempt on
8
+ * `EXPIRED_TOKEN_REFRESH_COOLDOWN_MS`, i.e. one mint per second, 60/min. That
9
+ * is correct against an unreachable server (recover the instant it returns) and
10
+ * exactly wrong against a rationing one: the retries spend the very budget they
11
+ * are waiting on, so the limiter stays tripped for as long as the app keeps
12
+ * issuing requests and the session can never heal itself.
13
+ *
14
+ * These tests pin the discrimination: a 429 lengthens the NEXT cooldown to the
15
+ * limiter's own window, every other failure keeps the token-state cooldowns, and
16
+ * a success clears it.
17
+ *
18
+ * `Date.now` is stubbed rather than using fake timers: the cooldown is a pure
19
+ * comparison against a stored epoch, so moving the clock is the whole
20
+ * experiment, and real microtasks keep the refresh promises resolving normally.
21
+ */
22
+ import { HttpService } from '../HttpService';
23
+
24
+ function createJwt(payload: Record<string, unknown>): string {
25
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
26
+ return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
27
+ }
28
+
29
+ /** The server's mint budget window — the interval the fix must wait out. */
30
+ const LIMITER_WINDOW_MS = 60_000;
31
+
32
+ /** Comfortably past the expired-token cooldown (1s) but inside the 429 window. */
33
+ const BETWEEN_COOLDOWNS_MS = 30_000;
34
+
35
+ describe('HttpService — refresh cooldown after a rate-limited mint', () => {
36
+ let clock = 1_700_000_000_000;
37
+
38
+ beforeEach(() => {
39
+ clock = 1_700_000_000_000;
40
+ jest.spyOn(Date, 'now').mockImplementation(() => clock);
41
+ });
42
+
43
+ afterEach(() => {
44
+ jest.restoreAllMocks();
45
+ });
46
+
47
+ /** A client whose CURRENT access token is already past `exp`. */
48
+ function expiredTokenClient(): HttpService {
49
+ const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
50
+ http.setTokens(createJwt({ userId: 'u', exp: Math.floor(clock / 1000) - 60 }));
51
+ return http;
52
+ }
53
+
54
+ it('retries an expired token ~1s after an ORDINARY failure (the unreachable-server lane is untouched)', async () => {
55
+ const http = expiredTokenClient();
56
+ const handler = jest.fn(async () => null);
57
+ http.setAuthRefreshHandler(handler);
58
+
59
+ expect(await http.refreshAccessToken('preflight')).toBeNull();
60
+ expect(handler).toHaveBeenCalledTimes(1);
61
+
62
+ clock += 1_001;
63
+ expect(await http.refreshAccessToken('preflight')).toBeNull();
64
+ expect(handler).toHaveBeenCalledTimes(2);
65
+ });
66
+
67
+ it('suppresses the retry for the limiter window after a 429, instead of hammering once a second', async () => {
68
+ const http = expiredTokenClient();
69
+ const handler = jest.fn(async () => {
70
+ // What `refreshDeviceSecretArm` does when the mint answers 429.
71
+ http.noteRefreshRateLimited();
72
+ return null;
73
+ });
74
+ http.setAuthRefreshHandler(handler);
75
+
76
+ expect(await http.refreshAccessToken('preflight')).toBeNull();
77
+ expect(handler).toHaveBeenCalledTimes(1);
78
+
79
+ // Past the expired-token cooldown. Without the 429 discrimination this is
80
+ // where the client resumes one mint per second and holds the limiter down.
81
+ clock += BETWEEN_COOLDOWNS_MS;
82
+ expect(await http.refreshAccessToken('preflight')).toBeNull();
83
+ expect(handler).toHaveBeenCalledTimes(1);
84
+
85
+ // Once the limiter's window has drained, exactly one probe goes out.
86
+ clock += LIMITER_WINDOW_MS;
87
+ expect(await http.refreshAccessToken('preflight')).toBeNull();
88
+ expect(handler).toHaveBeenCalledTimes(2);
89
+ });
90
+
91
+ it('holds a rate-limited client under the mint budget while requests keep arriving', async () => {
92
+ const http = expiredTokenClient();
93
+ const handler = jest.fn(async () => {
94
+ http.noteRefreshRateLimited();
95
+ return null;
96
+ });
97
+ http.setAuthRefreshHandler(handler);
98
+
99
+ // One simulated minute of a busy app: a request every 250ms, each one
100
+ // driving the preflight/401 refresh lane.
101
+ for (let elapsed = 0; elapsed < 60_000; elapsed += 250) {
102
+ await http.refreshAccessToken('preflight');
103
+ clock += 250;
104
+ }
105
+
106
+ // The server's budget is 30/min. The unfixed client issues 60.
107
+ expect(handler.mock.calls.length).toBeLessThanOrEqual(2);
108
+ });
109
+
110
+ it('clears the lengthened cooldown once a refresh succeeds', async () => {
111
+ const http = expiredTokenClient();
112
+ const fresh = createJwt({ userId: 'u', exp: Math.floor(clock / 1000) + 900 });
113
+ const handler = jest
114
+ .fn<Promise<string | null>, []>()
115
+ .mockImplementationOnce(async () => {
116
+ http.noteRefreshRateLimited();
117
+ return null;
118
+ })
119
+ .mockImplementationOnce(async () => fresh)
120
+ // A later ordinary failure must be back on the short cooldown, proving the
121
+ // 429 state did not become sticky.
122
+ .mockImplementation(async () => null);
123
+ http.setAuthRefreshHandler(handler);
124
+
125
+ await http.refreshAccessToken('preflight');
126
+ clock += LIMITER_WINDOW_MS + 1;
127
+ expect(await http.refreshAccessToken('preflight')).toBe(fresh);
128
+
129
+ // The planted token is fresh, so this is the still-valid (15s) cooldown.
130
+ clock += 16_000;
131
+ await http.refreshAccessToken('preflight');
132
+ expect(handler).toHaveBeenCalledTimes(3);
133
+
134
+ clock += 16_000;
135
+ await http.refreshAccessToken('preflight');
136
+ expect(handler).toHaveBeenCalledTimes(4);
137
+ });
138
+
139
+ it('returns to the short cooldown when a later failure is NOT a 429', async () => {
140
+ const http = expiredTokenClient();
141
+ const handler = jest
142
+ .fn<Promise<string | null>, []>()
143
+ .mockImplementationOnce(async () => {
144
+ http.noteRefreshRateLimited();
145
+ return null;
146
+ })
147
+ // The limiter window passed and the endpoint is merely erroring now.
148
+ .mockImplementation(async () => null);
149
+ http.setAuthRefreshHandler(handler);
150
+
151
+ await http.refreshAccessToken('preflight');
152
+ clock += LIMITER_WINDOW_MS + 1;
153
+ await http.refreshAccessToken('preflight');
154
+ expect(handler).toHaveBeenCalledTimes(2);
155
+
156
+ clock += 1_001;
157
+ await http.refreshAccessToken('preflight');
158
+ expect(handler).toHaveBeenCalledTimes(3);
159
+ });
160
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+
3
+ import { OxyServices } from '../OxyServices';
4
+
5
+ /**
6
+ * How an official service stops holding a secret WITHOUT a code change.
7
+ *
8
+ * `getServiceToken()` has always required an api key and secret. It now falls
9
+ * back to workload identity (ADR 0026) when there is none — which means the
10
+ * migration for each of Oxy's own services is "delete two environment
11
+ * variables", not "edit and redeploy an app".
12
+ *
13
+ * Three things have to be true for that to be safe, and each is asserted below:
14
+ *
15
+ * 1. A configured credential still wins. The fallback must not quietly change
16
+ * how an already-working service authenticates.
17
+ * 2. With no credential AND no way to attest — a laptop, a CI box — the caller
18
+ * gets the same "no credentials" error as before, not a confusing failure
19
+ * from a container credentials endpoint that is not there.
20
+ * 3. The token is cached, so a service does not mint one per request.
21
+ */
22
+
23
+ jest.mock('../server/workloadIdentity', () => ({
24
+ canAttestWorkloadIdentity: () => Boolean(process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI),
25
+ requestWorkloadServiceToken: jest.fn(async () => ({
26
+ token: 'token-from-attestation',
27
+ expiresIn: 3600,
28
+ appName: 'Mention',
29
+ })),
30
+ }));
31
+
32
+ import { requestWorkloadServiceToken } from '../server/workloadIdentity';
33
+
34
+ const attestations = requestWorkloadServiceToken as unknown as jest.Mock;
35
+
36
+ function client() {
37
+ return new OxyServices({ baseURL: 'https://api.oxy.so' });
38
+ }
39
+
40
+ describe('service token — workload fallback', () => {
41
+ const previous = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
42
+
43
+ beforeEach(() => {
44
+ attestations.mockClear();
45
+ process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/v2/credentials/abc';
46
+ });
47
+
48
+ afterAll(() => {
49
+ // Cleared by assignment rather than `delete`: Biome refuses `delete` on a
50
+ // property access, and an empty value reads as "cannot attest" everywhere
51
+ // that matters here.
52
+ process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = previous ?? '';
53
+ });
54
+
55
+ it('attests when no credential is configured', async () => {
56
+ await expect(client().getServiceToken()).resolves.toBe('token-from-attestation');
57
+ expect(attestations).toHaveBeenCalledWith({ baseUrl: 'https://api.oxy.so' });
58
+ });
59
+
60
+ it('mints once and serves the cached token afterwards', async () => {
61
+ const oxy = client();
62
+
63
+ await oxy.getServiceToken();
64
+ await oxy.getServiceToken();
65
+
66
+ expect(attestations).toHaveBeenCalledTimes(1);
67
+ });
68
+
69
+ it('leaves a configured credential alone', async () => {
70
+ const oxy = client();
71
+ oxy.configureServiceAuth('oxy_dk_example', 'secret-example');
72
+ // The credential path goes to the network; failing there is fine — what
73
+ // matters is that it did not silently attest instead.
74
+ await oxy.getServiceToken().catch(() => undefined);
75
+
76
+ expect(attestations).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('still says "no credentials" where nothing can be attested', async () => {
80
+ process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '';
81
+
82
+ await expect(client().getServiceToken()).rejects.toThrow(/Service credentials not provided/);
83
+ expect(attestations).not.toHaveBeenCalled();
84
+ });
85
+ });
@@ -1,96 +1,138 @@
1
1
  /**
2
- * Moving an identity: both sides derive the same key and code; the responder
3
- * receives exactly the identity that was declared; a relay that swaps a key
4
- * gets a different code and cannot open the identity; receipts cannot be forged.
2
+ * Giving a root to Commons (ADR 0024 D6): both sides derive the same key and code;
3
+ * the responder receives exactly the declared identity; the initiator's key is
4
+ * committed before the responder's is chosen, so a relay cannot steer both codes
5
+ * together; and the receipt binds the move, root, both keys and the relayed
6
+ * ciphertext.
5
7
  */
6
8
 
9
+ import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
10
+ import { signMessage } from '@oxy.so/protocol';
7
11
  import {
8
12
  buildMoveQrPayload,
13
+ createMoveCommitment,
9
14
  deriveMoveKey,
10
15
  deriveMoveSas,
16
+ digestMoveCiphertext,
11
17
  generateMoveEphemeralKeyPair,
12
- IDENTITY_MOVE_ACTIONS,
13
18
  openMovedIdentity,
14
19
  parseMoveQrPayload,
15
20
  sealIdentityForMove,
16
- signMoveAction,
21
+ signMoveReceipt,
22
+ verifyMoveCommitment,
17
23
  verifyMoveReceipt,
18
24
  } from '../identityMove';
19
- import { generateWebIdentity } from '../webIdentityCarrier';
25
+ import { deriveIdentityFromMnemonic, generateWebIdentity } from '../webIdentityCarrier';
20
26
 
21
27
  const MOVE_ID = '0123456789abcdef0123456789abcdef';
22
-
23
- describe('a move between two honest carriers', () => {
24
- it('delivers the declared identity and both screens show the same code', () => {
25
- const identity = generateWebIdentity();
26
- const web = generateMoveEphemeralKeyPair();
27
- const commons = generateMoveEphemeralKeyPair();
28
-
29
- const webKey = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
30
- const commonsKey = deriveMoveKey(commons.privateKey, web.publicKey, MOVE_ID);
31
- expect(Buffer.from(webKey).equals(Buffer.from(commonsKey))).toBe(true);
32
-
33
- expect(deriveMoveSas(MOVE_ID, web.publicKey, commons.publicKey)).toMatch(/^\d{6}$/);
34
-
35
- const sealed = sealIdentityForMove(identity, webKey, MOVE_ID);
36
- expect(JSON.stringify(sealed)).not.toContain(identity.privateKey);
37
-
38
- const received = openMovedIdentity(sealed, commonsKey, MOVE_ID, identity.publicKey);
39
- expect(received).toEqual(identity);
28
+ const MNEMONIC_24 =
29
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art';
30
+
31
+ function code(initiator: string, responder: string, commitment: string, moveId = MOVE_ID): string {
32
+ return deriveMoveSas({ moveId, initiatorEphemeralPublicKey: initiator, responderEphemeralPublicKey: responder, initiatorCommitment: commitment });
33
+ }
34
+
35
+ describe('a move between two honest sides', () => {
36
+ it('delivers the declared identity — 12 or 24 words — and both screens show the same code', () => {
37
+ for (const identity of [generateWebIdentity(), deriveIdentityFromMnemonic(MNEMONIC_24)]) {
38
+ const web = generateMoveEphemeralKeyPair();
39
+ const commons = generateMoveEphemeralKeyPair();
40
+ const { commitment, nonce } = createMoveCommitment(web.publicKey);
41
+ expect(verifyMoveCommitment(web.publicKey, nonce, commitment)).toBe(true);
42
+
43
+ const webKey = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
44
+ const commonsKey = deriveMoveKey(commons.privateKey, web.publicKey, MOVE_ID);
45
+ expect(Buffer.from(webKey).equals(Buffer.from(commonsKey))).toBe(true);
46
+ expect(code(web.publicKey, commons.publicKey, commitment)).toMatch(/^\d{6}$/);
47
+ expect(code(web.publicKey, commons.publicKey, commitment, MOVE_ID.toUpperCase())).toBe(code(web.publicKey, commons.publicKey, commitment));
48
+
49
+ const sealed = sealIdentityForMove(identity, webKey, MOVE_ID);
50
+ expect(JSON.stringify(sealed)).not.toContain(identity.privateKey);
51
+ expect(openMovedIdentity(sealed, commonsKey, MOVE_ID, identity.publicKey)).toEqual(identity);
52
+ }
40
53
  });
41
54
  });
42
55
 
43
- describe('a relay that substitutes a key', () => {
56
+ describe('a relay that substitutes keys', () => {
57
+ it('cannot reveal a key the commitment does not open', () => {
58
+ const web = generateSecp256k1KeyPair();
59
+ const { commitment, nonce } = createMoveCommitment(web.publicKey);
60
+ expect(verifyMoveCommitment(generateSecp256k1KeyPair().publicKey, nonce, commitment)).toBe(false);
61
+ expect(verifyMoveCommitment(web.publicKey, 'ab'.repeat(32), commitment)).toBe(false);
62
+ });
63
+
44
64
  it('shows a different code on each screen and cannot open the identity', () => {
45
65
  const identity = generateWebIdentity();
46
66
  const web = generateMoveEphemeralKeyPair();
47
67
  const commons = generateMoveEphemeralKeyPair();
48
68
  const attacker = generateMoveEphemeralKeyPair();
69
+ const { commitment } = createMoveCommitment(web.publicKey);
70
+ const attackerCommitment = createMoveCommitment(attacker.publicKey).commitment;
49
71
 
50
- // Web talks to the attacker thinking it is Commons; Commons talks to the attacker thinking it is the web.
51
- const webSas = deriveMoveSas(MOVE_ID, web.publicKey, attacker.publicKey);
52
- const commonsSas = deriveMoveSas(MOVE_ID, attacker.publicKey, commons.publicKey);
53
- expect(webSas).not.toBe(commonsSas);
72
+ expect(code(web.publicKey, attacker.publicKey, commitment)).not.toBe(code(attacker.publicKey, commons.publicKey, attackerCommitment));
54
73
 
55
- // Even sealed to the attacker, Commons' own key cannot open it.
56
74
  const sealedToAttacker = sealIdentityForMove(identity, deriveMoveKey(web.privateKey, attacker.publicKey, MOVE_ID), MOVE_ID);
57
75
  expect(() =>
58
76
  openMovedIdentity(sealedToAttacker, deriveMoveKey(commons.privateKey, web.publicKey, MOVE_ID), MOVE_ID, identity.publicKey),
59
77
  ).toThrow('could not be opened');
60
78
  });
61
79
 
62
- it('refuses a sealed identity that is not the one the move declared', () => {
63
- const declared = generateWebIdentity();
64
- const other = generateWebIdentity();
65
- const web = generateMoveEphemeralKeyPair();
66
- const commons = generateMoveEphemeralKeyPair();
67
- const key = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
68
-
69
- const sealedOther = sealIdentityForMove(other, key, MOVE_ID);
70
- expect(() => openMovedIdentity(sealedOther, key, MOVE_ID, declared.publicKey)).toThrow();
80
+ it('changes the code when any input changes, including the commitment', () => {
81
+ const web = generateSecp256k1KeyPair();
82
+ const commons = generateSecp256k1KeyPair();
83
+ const { commitment } = createMoveCommitment(web.publicKey);
84
+ const base = code(web.publicKey, commons.publicKey, commitment);
85
+ expect(code(web.publicKey, commons.publicKey, createMoveCommitment(web.publicKey).commitment)).not.toBe(base);
86
+ expect(code(web.publicKey, generateSecp256k1KeyPair().publicKey, commitment)).not.toBe(base);
87
+ expect(code(commons.publicKey, web.publicKey, commitment)).not.toBe(base);
71
88
  });
72
89
 
73
- it('binds the ciphertext to its move id', () => {
74
- const identity = generateWebIdentity();
90
+ it('refuses a sealed identity that is not the one the move declared, or another move’s ciphertext', () => {
91
+ const declared = generateWebIdentity();
75
92
  const web = generateMoveEphemeralKeyPair();
76
93
  const commons = generateMoveEphemeralKeyPair();
77
94
  const key = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
78
- const sealed = sealIdentityForMove(identity, key, MOVE_ID);
79
- expect(() => openMovedIdentity(sealed, key, 'ffffffffffffffffffffffffffffffff', identity.publicKey)).toThrow();
95
+ expect(() => openMovedIdentity(sealIdentityForMove(generateWebIdentity(), key, MOVE_ID), key, MOVE_ID, declared.publicKey)).toThrow();
96
+ expect(() => openMovedIdentity(sealIdentityForMove(declared, key, MOVE_ID), key, 'ffffffffffffffffffffffffffffffff', declared.publicKey)).toThrow();
80
97
  });
81
98
  });
82
99
 
83
100
  describe('receipts', () => {
84
- it('verify only when signed by the moved identity for this move', async () => {
101
+ function transfer() {
85
102
  const identity = generateWebIdentity();
86
- const receipt = await signMoveAction(identity, IDENTITY_MOVE_ACTIONS.received, MOVE_ID);
87
-
88
- expect(await verifyMoveReceipt(identity.publicKey, MOVE_ID, receipt)).toBe(true);
89
- expect(await verifyMoveReceipt(identity.publicKey, 'ffffffffffffffffffffffffffffffff', receipt)).toBe(false);
90
- expect(await verifyMoveReceipt(generateWebIdentity().publicKey, MOVE_ID, receipt)).toBe(false);
103
+ const web = generateMoveEphemeralKeyPair();
104
+ const commons = generateMoveEphemeralKeyPair();
105
+ const sealed = sealIdentityForMove(identity, deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID), MOVE_ID);
106
+ const claims = {
107
+ moveId: MOVE_ID,
108
+ rootPublicKey: identity.publicKey,
109
+ initiatorEphemeralPublicKey: web.publicKey,
110
+ responderEphemeralPublicKey: commons.publicKey,
111
+ ciphertextDigest: digestMoveCiphertext(sealed),
112
+ };
113
+ return { identity, sealed, claims };
114
+ }
115
+
116
+ it('verify for exactly the move, root, keys and ciphertext they were made over', async () => {
117
+ const { identity, sealed, claims } = transfer();
118
+ const receipt = await signMoveReceipt((message) => signMessage(message, identity.privateKey), claims);
119
+ expect(await verifyMoveReceipt(claims, receipt.signature)).toBe(true);
120
+
121
+ const other = { ...sealed, ciphertext: `${sealed.ciphertext.slice(0, -2)}00` };
122
+ for (const changed of [
123
+ { ...claims, moveId: 'ffffffffffffffffffffffffffffffff' },
124
+ { ...claims, responderEphemeralPublicKey: generateSecp256k1KeyPair().publicKey },
125
+ { ...claims, initiatorEphemeralPublicKey: generateSecp256k1KeyPair().publicKey },
126
+ { ...claims, ciphertextDigest: digestMoveCiphertext(other) },
127
+ ]) {
128
+ expect(await verifyMoveReceipt(changed, receipt.signature)).toBe(false);
129
+ }
130
+ });
91
131
 
92
- const sealProof = await signMoveAction(identity, IDENTITY_MOVE_ACTIONS.seal, MOVE_ID);
93
- expect(await verifyMoveReceipt(identity.publicKey, MOVE_ID, sealProof)).toBe(false);
132
+ it('are not the root’s if another key signed them', async () => {
133
+ const { claims } = transfer();
134
+ const forged = await signMoveReceipt((message) => signMessage(message, generateWebIdentity().privateKey), claims);
135
+ expect(await verifyMoveReceipt(claims, forged.signature)).toBe(false);
94
136
  });
95
137
  });
96
138