@oxyhq/core 3.10.0 → 3.11.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 (105) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +9 -2
  3. package/dist/cjs/HttpService.js +27 -9
  4. package/dist/cjs/OxyServices.base.js +3 -2
  5. package/dist/cjs/crypto/canonicalJson.js +107 -0
  6. package/dist/cjs/crypto/keyManager.js +67 -8
  7. package/dist/cjs/crypto/signatureService.js +103 -0
  8. package/dist/cjs/i18n/locales/en-US.json +9 -0
  9. package/dist/cjs/i18n/locales/es-ES.json +9 -0
  10. package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
  11. package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
  12. package/dist/cjs/index.js +15 -5
  13. package/dist/cjs/mixins/OxyServices.assets.js +45 -7
  14. package/dist/cjs/mixins/OxyServices.auth.js +190 -1
  15. package/dist/cjs/mixins/OxyServices.identity.js +291 -0
  16. package/dist/cjs/mixins/OxyServices.sso.js +28 -1
  17. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  18. package/dist/cjs/mixins/OxyServices.utility.js +52 -23
  19. package/dist/cjs/mixins/index.js +3 -0
  20. package/dist/cjs/server/cors.js +20 -21
  21. package/dist/cjs/server/rateLimit.js +32 -8
  22. package/dist/cjs/utils/fapiAutoDetect.js +12 -42
  23. package/dist/cjs/utils/ssoReturn.js +1 -1
  24. package/dist/esm/.tsbuildinfo +1 -1
  25. package/dist/esm/AuthManager.js +9 -2
  26. package/dist/esm/HttpService.js +27 -9
  27. package/dist/esm/OxyServices.base.js +3 -2
  28. package/dist/esm/crypto/canonicalJson.js +104 -0
  29. package/dist/esm/crypto/keyManager.js +67 -8
  30. package/dist/esm/crypto/signatureService.js +102 -0
  31. package/dist/esm/i18n/locales/en-US.json +9 -0
  32. package/dist/esm/i18n/locales/es-ES.json +9 -0
  33. package/dist/esm/i18n/locales/locales/en-US.json +9 -0
  34. package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
  35. package/dist/esm/index.js +10 -2
  36. package/dist/esm/mixins/OxyServices.assets.js +45 -7
  37. package/dist/esm/mixins/OxyServices.auth.js +190 -1
  38. package/dist/esm/mixins/OxyServices.identity.js +287 -0
  39. package/dist/esm/mixins/OxyServices.sso.js +28 -1
  40. package/dist/esm/mixins/OxyServices.user.js +1 -0
  41. package/dist/esm/mixins/OxyServices.utility.js +52 -23
  42. package/dist/esm/mixins/index.js +3 -0
  43. package/dist/esm/server/cors.js +20 -21
  44. package/dist/esm/server/rateLimit.js +32 -8
  45. package/dist/esm/utils/fapiAutoDetect.js +12 -41
  46. package/dist/esm/utils/ssoReturn.js +1 -1
  47. package/dist/types/.tsbuildinfo +1 -1
  48. package/dist/types/HttpService.d.ts +3 -0
  49. package/dist/types/OxyServices.d.ts +2 -2
  50. package/dist/types/crypto/canonicalJson.d.ts +44 -0
  51. package/dist/types/crypto/keyManager.d.ts +7 -0
  52. package/dist/types/crypto/signatureService.d.ts +61 -0
  53. package/dist/types/index.d.ts +7 -3
  54. package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
  55. package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
  56. package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
  57. package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
  58. package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
  59. package/dist/types/mixins/index.d.ts +2 -1
  60. package/dist/types/models/interfaces.d.ts +3 -0
  61. package/dist/types/server/cors.d.ts +5 -5
  62. package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
  63. package/dist/types/utils/ssoReturn.d.ts +1 -1
  64. package/package.json +3 -2
  65. package/src/AuthManager.ts +8 -2
  66. package/src/HttpService.ts +36 -8
  67. package/src/OxyServices.base.ts +3 -2
  68. package/src/OxyServices.ts +1 -1
  69. package/src/__tests__/authManager.security.test.ts +31 -0
  70. package/src/__tests__/authSocket.test.ts +96 -0
  71. package/src/__tests__/httpServiceCsrf.test.ts +75 -0
  72. package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
  73. package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
  74. package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
  75. package/src/crypto/__tests__/signedRecord.test.ts +125 -0
  76. package/src/crypto/canonicalJson.ts +120 -0
  77. package/src/crypto/keyManager.ts +62 -12
  78. package/src/crypto/signatureService.ts +126 -0
  79. package/src/i18n/locales/en-US.json +9 -0
  80. package/src/i18n/locales/es-ES.json +9 -0
  81. package/src/index.ts +28 -3
  82. package/src/mixins/OxyServices.assets.ts +56 -7
  83. package/src/mixins/OxyServices.auth.ts +309 -1
  84. package/src/mixins/OxyServices.identity.ts +445 -0
  85. package/src/mixins/OxyServices.sso.ts +30 -1
  86. package/src/mixins/OxyServices.user.ts +1 -0
  87. package/src/mixins/OxyServices.utility.ts +57 -23
  88. package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
  89. package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
  90. package/src/mixins/__tests__/assetUpload.test.ts +191 -0
  91. package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
  92. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
  93. package/src/mixins/__tests__/serviceAuth.test.ts +49 -2
  94. package/src/mixins/__tests__/sso.test.ts +31 -0
  95. package/src/mixins/index.ts +4 -0
  96. package/src/models/interfaces.ts +3 -0
  97. package/src/server/__tests__/cors.test.ts +5 -1
  98. package/src/server/__tests__/rateLimit.test.ts +116 -0
  99. package/src/server/cors.ts +25 -20
  100. package/src/server/rateLimit.ts +39 -8
  101. package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
  102. package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
  103. package/src/utils/__tests__/ssoReturn.test.ts +1 -1
  104. package/src/utils/fapiAutoDetect.ts +12 -39
  105. package/src/utils/ssoReturn.ts +2 -2
@@ -0,0 +1,277 @@
1
+ /**
2
+ * "Sign in with Oxy" handoff tests (Workstream C).
3
+ *
4
+ * Stubs `makeRequest` (and the shared challenge/sign primitives) so the tests
5
+ * run with no network. We assert the exact request bodies the RP and the
6
+ * approver send — these are the load-bearing coordination points with the C2
7
+ * server endpoints — plus the native-vs-web behaviour of the shared-key SSO.
8
+ */
9
+
10
+ import type { SessionLoginResponse } from '../../models/session';
11
+ import type { ChallengeResponse } from '../OxyServices.auth';
12
+ import { OxyServices } from '../../OxyServices';
13
+ import { KeyManager } from '../../crypto/keyManager';
14
+ import { SignatureService } from '../../crypto/signatureService';
15
+
16
+ const challengeFixture: ChallengeResponse = {
17
+ challenge: 'chal-xyz',
18
+ expiresAt: '2026-06-26T00:05:00.000Z',
19
+ };
20
+
21
+ const sessionFixture: SessionLoginResponse = {
22
+ sessionId: 's1',
23
+ deviceId: 'd1',
24
+ expiresAt: '2026-06-26T00:05:00.000Z',
25
+ accessToken: 'at-1',
26
+ user: { id: 'u1', username: 'nate', name: { displayName: 'Nate' } },
27
+ };
28
+
29
+ describe('OxyServices — "Sign in with Oxy" handoff', () => {
30
+ let oxy: OxyServices;
31
+ let makeRequestSpy: jest.SpyInstance;
32
+
33
+ beforeEach(() => {
34
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
35
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
36
+ });
37
+
38
+ afterEach(() => {
39
+ jest.restoreAllMocks();
40
+ });
41
+
42
+ describe('startCommonsSignIn (relying party)', () => {
43
+ it('generates a client-side sessionToken and POSTs /auth/session/create', async () => {
44
+ jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
45
+ jest.spyOn(SignatureService, 'generateChallenge').mockResolvedValue('secret-session-token');
46
+ makeRequestSpy.mockResolvedValue({
47
+ authorizeCode: 'code-1',
48
+ qrPayload: 'oxycommons://approve?v=1&code=code-1',
49
+ status: 'pending',
50
+ expiresAt: 1700000300000,
51
+ });
52
+
53
+ const handle = await oxy.startCommonsSignIn({ clientId: 'oxy_dk_test' });
54
+
55
+ // expiry is client-proposed (now + 5 min) on the request...
56
+ expect(makeRequestSpy).toHaveBeenCalledWith(
57
+ 'POST',
58
+ '/auth/session/create',
59
+ { sessionToken: 'secret-session-token', expiresAt: 1700000300000, clientId: 'oxy_dk_test' },
60
+ expect.objectContaining({ cache: false }),
61
+ );
62
+ // ...and the handle carries the SECRET token + the server's public code/payload.
63
+ expect(handle).toEqual({
64
+ sessionToken: 'secret-session-token',
65
+ authorizeCode: 'code-1',
66
+ qrPayload: 'oxycommons://approve?v=1&code=code-1',
67
+ expiresAt: 1700000300000,
68
+ status: 'pending',
69
+ });
70
+ // The secret token must never leak into the QR payload.
71
+ expect(handle.qrPayload).not.toContain('secret-session-token');
72
+ });
73
+
74
+ it('falls back to the client-proposed expiry when the server omits it', async () => {
75
+ jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
76
+ jest.spyOn(SignatureService, 'generateChallenge').mockResolvedValue('tok');
77
+ makeRequestSpy.mockResolvedValue({
78
+ authorizeCode: 'code-2',
79
+ qrPayload: 'oxycommons://approve?v=1&code=code-2',
80
+ status: 'pending',
81
+ });
82
+
83
+ const handle = await oxy.startCommonsSignIn({ clientId: 'oxy_dk_test' });
84
+ expect(handle.expiresAt).toBe(1700000000000 + 5 * 60 * 1000);
85
+ });
86
+ });
87
+
88
+ describe('pollCommonsSignIn (relying party)', () => {
89
+ it('GETs the session status without cache/retry', async () => {
90
+ makeRequestSpy.mockResolvedValue({ authorized: true, sessionId: 's1' });
91
+
92
+ const result = await oxy.pollCommonsSignIn('secret-session-token');
93
+
94
+ expect(result).toEqual({ authorized: true, sessionId: 's1' });
95
+ expect(makeRequestSpy).toHaveBeenCalledWith(
96
+ 'GET',
97
+ '/auth/session/status/secret-session-token',
98
+ undefined,
99
+ expect.objectContaining({ cache: false, retry: false }),
100
+ );
101
+ });
102
+ });
103
+
104
+ describe('getCommonsApprovalInfo (approver)', () => {
105
+ it('GETs the server-resolved approval info by authorizeCode', async () => {
106
+ const info = {
107
+ application: {
108
+ id: 'app1',
109
+ name: 'Mention',
110
+ type: 'first_party' as const,
111
+ isOfficial: true,
112
+ isInternal: false,
113
+ scopes: ['profile'],
114
+ },
115
+ scopes: ['profile'],
116
+ boundOrigin: 'https://mention.earth',
117
+ expiresAt: 1700000300000,
118
+ status: 'pending',
119
+ };
120
+ makeRequestSpy.mockResolvedValue(info);
121
+
122
+ const result = await oxy.getCommonsApprovalInfo('code-1');
123
+
124
+ expect(result).toEqual(info);
125
+ expect(makeRequestSpy).toHaveBeenCalledWith(
126
+ 'GET',
127
+ '/auth/session/approve-info/code-1',
128
+ undefined,
129
+ expect.objectContaining({ cache: false }),
130
+ );
131
+ });
132
+ });
133
+
134
+ describe('approveCommonsSignIn (approver)', () => {
135
+ it('requests a challenge, signs with the PRIMARY key, and POSTs authorize-signed', async () => {
136
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue('pub-primary');
137
+ const requestChallengeSpy = jest
138
+ .spyOn(oxy, 'requestChallenge')
139
+ .mockResolvedValue(challengeFixture);
140
+ const signChallengeSpy = jest.spyOn(SignatureService, 'signChallenge').mockResolvedValue({
141
+ challenge: 'sig-primary',
142
+ publicKey: 'pub-primary',
143
+ timestamp: 1700000000123,
144
+ });
145
+ // authorize-signed is the only network call (challenge is mocked above).
146
+ makeRequestSpy.mockResolvedValue({ success: true });
147
+
148
+ const result = await oxy.approveCommonsSignIn({
149
+ authorizeCode: 'code-1',
150
+ deviceName: 'iPhone',
151
+ });
152
+
153
+ expect(requestChallengeSpy).toHaveBeenCalledWith('pub-primary');
154
+ expect(signChallengeSpy).toHaveBeenCalledWith('chal-xyz');
155
+ expect(makeRequestSpy).toHaveBeenCalledWith(
156
+ 'POST',
157
+ '/auth/session/authorize-signed/code-1',
158
+ {
159
+ publicKey: 'pub-primary',
160
+ challenge: 'chal-xyz',
161
+ signature: 'sig-primary',
162
+ timestamp: 1700000000123,
163
+ deviceName: 'iPhone',
164
+ },
165
+ expect.objectContaining({ cache: false }),
166
+ );
167
+ expect(result).toEqual({ success: true });
168
+ });
169
+
170
+ it('omits deviceName/deviceFingerprint when not provided', async () => {
171
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue('pub-primary');
172
+ jest.spyOn(oxy, 'requestChallenge').mockResolvedValue(challengeFixture);
173
+ jest.spyOn(SignatureService, 'signChallenge').mockResolvedValue({
174
+ challenge: 'sig-primary',
175
+ publicKey: 'pub-primary',
176
+ timestamp: 1700000000123,
177
+ });
178
+ makeRequestSpy.mockResolvedValue({ success: true });
179
+
180
+ await oxy.approveCommonsSignIn({ authorizeCode: 'code-1' });
181
+
182
+ expect(makeRequestSpy).toHaveBeenCalledWith(
183
+ 'POST',
184
+ '/auth/session/authorize-signed/code-1',
185
+ {
186
+ publicKey: 'pub-primary',
187
+ challenge: 'chal-xyz',
188
+ signature: 'sig-primary',
189
+ timestamp: 1700000000123,
190
+ },
191
+ expect.objectContaining({ cache: false }),
192
+ );
193
+ });
194
+
195
+ it('throws (no network) when the device has no primary identity', async () => {
196
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
197
+ await expect(oxy.approveCommonsSignIn({ authorizeCode: 'code-1' })).rejects.toThrow(
198
+ /No identity found/,
199
+ );
200
+ expect(makeRequestSpy).not.toHaveBeenCalled();
201
+ });
202
+ });
203
+
204
+ describe('denyCommonsSignIn (approver)', () => {
205
+ it('POSTs /auth/session/deny/:authorizeCode', async () => {
206
+ makeRequestSpy.mockResolvedValue({ success: true });
207
+
208
+ const result = await oxy.denyCommonsSignIn('code-1');
209
+
210
+ expect(result).toEqual({ success: true });
211
+ expect(makeRequestSpy).toHaveBeenCalledWith(
212
+ 'POST',
213
+ '/auth/session/deny/code-1',
214
+ undefined,
215
+ expect.objectContaining({ cache: false }),
216
+ );
217
+ });
218
+ });
219
+
220
+ describe('signInWithSharedIdentity (Mechanism A — same-device SSO)', () => {
221
+ it('mints a session from the shared key when one exists (native)', async () => {
222
+ jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
223
+ jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue('shared-pub');
224
+ const requestChallengeSpy = jest
225
+ .spyOn(oxy, 'requestChallenge')
226
+ .mockResolvedValue({ challenge: 'chal-shared', expiresAt: '2026-06-26T00:05:00.000Z' });
227
+ jest.spyOn(SignatureService, 'signChallengeWithSharedKey').mockResolvedValue({
228
+ challenge: 'sig-shared',
229
+ publicKey: 'shared-pub',
230
+ timestamp: 1700000000456,
231
+ });
232
+ const verifyChallengeSpy = jest
233
+ .spyOn(oxy, 'verifyChallenge')
234
+ .mockResolvedValue(sessionFixture);
235
+
236
+ const result = await oxy.signInWithSharedIdentity({
237
+ deviceName: 'iPad',
238
+ deviceFingerprint: 'fp-1',
239
+ });
240
+
241
+ expect(requestChallengeSpy).toHaveBeenCalledWith('shared-pub');
242
+ expect(verifyChallengeSpy).toHaveBeenCalledWith(
243
+ 'shared-pub',
244
+ 'chal-shared',
245
+ 'sig-shared',
246
+ 1700000000456,
247
+ 'iPad',
248
+ 'fp-1',
249
+ );
250
+ expect(result).toEqual(sessionFixture);
251
+ });
252
+
253
+ it('returns null (no network) when no shared identity exists — the web case', async () => {
254
+ // hasSharedIdentity() is already false on web; emulate that verdict.
255
+ jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(false);
256
+ const requestChallengeSpy = jest.spyOn(oxy, 'requestChallenge');
257
+ const verifyChallengeSpy = jest.spyOn(oxy, 'verifyChallenge');
258
+
259
+ const result = await oxy.signInWithSharedIdentity();
260
+
261
+ expect(result).toBeNull();
262
+ expect(requestChallengeSpy).not.toHaveBeenCalled();
263
+ expect(verifyChallengeSpy).not.toHaveBeenCalled();
264
+ });
265
+
266
+ it('returns null when the shared public key is unexpectedly absent', async () => {
267
+ jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
268
+ jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue(null);
269
+ const verifyChallengeSpy = jest.spyOn(oxy, 'verifyChallenge');
270
+
271
+ const result = await oxy.signInWithSharedIdentity();
272
+
273
+ expect(result).toBeNull();
274
+ expect(verifyChallengeSpy).not.toHaveBeenCalled();
275
+ });
276
+ });
277
+ });
@@ -49,6 +49,19 @@ describe('OxyServices.getFileDownloadUrl', () => {
49
49
  'https://cloud.oxy.so/a%2Fb%20c?variant=large%20size',
50
50
  );
51
51
  });
52
+
53
+ it('can omit the token for persisted public image URLs while authenticated', () => {
54
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
55
+ oxy.setTokens('access-token-abc');
56
+
57
+ const url = oxy.getFileDownloadUrl('file123', 'thumb', undefined, {
58
+ omitToken: true,
59
+ });
60
+
61
+ expect(url).toBe('https://cloud.oxy.so/file123?variant=thumb');
62
+ expect(url).not.toContain('access-token-abc');
63
+ expect(url).not.toContain('token=');
64
+ });
52
65
  });
53
66
 
54
67
  describe('signed / private assets → authenticated API origin', () => {
@@ -312,6 +312,25 @@ describe('H1: getServiceToken per-credential cache + secret verification', () =>
312
312
  );
313
313
  });
314
314
 
315
+ it('does not poison the cache when initial token fetch fails for an apiKey', async () => {
316
+ makeRequestSpy
317
+ .mockRejectedValueOnce(new Error('invalid service credentials'))
318
+ .mockResolvedValueOnce({
319
+ token: 'token-A',
320
+ expiresIn: 3600,
321
+ appName: 'tenant-A',
322
+ });
323
+
324
+ await expect(oxy.getServiceToken('key-A', 'attacker-secret')).rejects.toThrow(
325
+ 'invalid service credentials',
326
+ );
327
+
328
+ const token = await oxy.getServiceToken('key-A', 'secret-A');
329
+
330
+ expect(token).toBe('token-A');
331
+ expect(makeRequestSpy).toHaveBeenCalledTimes(2);
332
+ });
333
+
315
334
  it('refreshes the cached token when it expires (using the correct stored secret)', async () => {
316
335
  // First token already past its buffer window.
317
336
  makeRequestSpy.mockResolvedValueOnce({
@@ -672,9 +691,9 @@ describe('requireScope() middleware', () => {
672
691
  expect(res.headersSent).toBe(false);
673
692
  });
674
693
 
675
- it('allows requests where the delegation grant carries the required scope', () => {
694
+ it('allows delegated requests only when both app and delegation carry the required scope', () => {
676
695
  const req = makeReq();
677
- req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: [] };
696
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['user:read'] };
678
697
  req.serviceActingAs = { userId: 'u-1', scopes: ['user:read'] };
679
698
  const res = makeRes();
680
699
  const next = jest.fn();
@@ -684,6 +703,34 @@ describe('requireScope() middleware', () => {
684
703
  expect(next).toHaveBeenCalledTimes(1);
685
704
  });
686
705
 
706
+ it('rejects delegated requests when only the app carries the required scope', () => {
707
+ const req = makeReq();
708
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['files:write'] };
709
+ req.serviceActingAs = { userId: 'u-1', scopes: ['profile:read'] };
710
+ const res = makeRes();
711
+ const next = jest.fn();
712
+
713
+ oxy.requireScope('files:write')(req as unknown as never, res as unknown as never, next as unknown as never);
714
+
715
+ expect(next).not.toHaveBeenCalled();
716
+ expect(res.statusCode).toBe(403);
717
+ expect(res.body).toMatchObject({ code: 'INSUFFICIENT_SCOPE' });
718
+ });
719
+
720
+ it('rejects delegated requests when only the delegation carries the required scope', () => {
721
+ const req = makeReq();
722
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['profile:read'] };
723
+ req.serviceActingAs = { userId: 'u-1', scopes: ['files:write'] };
724
+ const res = makeRes();
725
+ const next = jest.fn();
726
+
727
+ oxy.requireScope('files:write')(req as unknown as never, res as unknown as never, next as unknown as never);
728
+
729
+ expect(next).not.toHaveBeenCalled();
730
+ expect(res.statusCode).toBe(403);
731
+ expect(res.body).toMatchObject({ code: 'INSUFFICIENT_SCOPE' });
732
+ });
733
+
687
734
  it('rejects requests missing the required scope with 403', () => {
688
735
  const req = makeReq();
689
736
  req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['user:read'] };
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { OxyServices } from '../../OxyServices';
13
13
  import { generateSsoState } from '../OxyServices.sso';
14
+ import { ssoStateKey } from '../../utils/ssoBounce';
14
15
 
15
16
  interface FetchCall {
16
17
  url: string;
@@ -118,6 +119,36 @@ describe('OxyServices.exchangeSsoCode', () => {
118
119
  expect(calls).toHaveLength(0);
119
120
  });
120
121
 
122
+ it('rejects a browser SSO exchange when stored state is not echoed', async () => {
123
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
124
+ const { calls } = mockFetchOnce(VALID_BODY);
125
+ const storage = new Map([[ssoStateKey('https://rp.example'), 'expected-state']]);
126
+ const previousWindow = (globalThis as unknown as { window?: unknown }).window;
127
+
128
+ Object.defineProperty(globalThis, 'window', {
129
+ configurable: true,
130
+ value: {
131
+ location: { origin: 'https://rp.example' },
132
+ sessionStorage: {
133
+ getItem: (key: string) => storage.get(key) ?? null,
134
+ },
135
+ },
136
+ });
137
+
138
+ try {
139
+ await expect(oxy.exchangeSsoCode('opaque-code-123', 'attacker-state')).rejects.toThrow(
140
+ 'SSO exchange state mismatch',
141
+ );
142
+ expect(calls).toHaveLength(0);
143
+ expect(oxy.hasValidToken()).toBe(false);
144
+ } finally {
145
+ Object.defineProperty(globalThis, 'window', {
146
+ configurable: true,
147
+ value: previousWindow,
148
+ });
149
+ }
150
+ });
151
+
121
152
  it('throws and does not plant a token on a non-2xx response', async () => {
122
153
  const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
123
154
  mockFetchOnce({ error: 'invalid_code' }, false, 400);
@@ -12,6 +12,7 @@ import { OxyServicesSilentAuthMixin } from './OxyServices.silent';
12
12
  import { OxyServicesRedirectAuthMixin } from './OxyServices.redirect';
13
13
  import { OxyServicesSsoMixin } from './OxyServices.sso';
14
14
  import { OxyServicesUserMixin } from './OxyServices.user';
15
+ import { OxyServicesIdentityMixin } from './OxyServices.identity';
15
16
  import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
16
17
  import { OxyServicesLanguageMixin } from './OxyServices.language';
17
18
  import { OxyServicesPaymentMixin } from './OxyServices.payment';
@@ -46,6 +47,7 @@ type AllMixinInstances =
46
47
  & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>>
47
48
  & InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>>
48
49
  & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>>
50
+ & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>>
49
51
  & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>>
50
52
  & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>>
51
53
  & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>>
@@ -108,6 +110,8 @@ const MIXIN_PIPELINE: MixinFunction[] = [
108
110
 
109
111
  // User management (requires auth)
110
112
  OxyServicesUserMixin,
113
+ // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping)
114
+ OxyServicesIdentityMixin,
111
115
  OxyServicesPrivacyMixin,
112
116
 
113
117
  // Feature mixins
@@ -113,6 +113,9 @@ export interface User {
113
113
  */
114
114
  name: UserNameResponse;
115
115
  bio?: string;
116
+ phone?: string;
117
+ address?: string;
118
+ birthday?: string;
116
119
  location?: string;
117
120
  website?: string;
118
121
  createdAt?: string;
@@ -34,7 +34,7 @@ function makeNext(): NextFunction & jest.Mock {
34
34
  }
35
35
 
36
36
  describe('@oxyhq/core/server createOxyCors', () => {
37
- it('allows the Oxy apex family (apex + any subdomain) and echoes the exact origin', () => {
37
+ it('allows the HTTPS Oxy apex family (apex + one-level subdomains) and echoes the exact origin', () => {
38
38
  const mw = createOxyCors();
39
39
  for (const origin of [
40
40
  'https://oxy.so',
@@ -74,6 +74,10 @@ describe('@oxyhq/core/server createOxyCors', () => {
74
74
  'https://oxy.so.evil.com', // suffix attack
75
75
  'https://notoxy.so', // different apex
76
76
  'https://example.com',
77
+ 'http://oxy.so',
78
+ 'http://auth.oxy.so',
79
+ 'https://deep.attacker-controlled.oxy.so',
80
+ 'https://api.oxy.so:8443',
77
81
  ]) {
78
82
  const req = makeRequest('GET', origin);
79
83
  const res = makeResponse();
@@ -0,0 +1,116 @@
1
+ import type { NextFunction, Request, RequestHandler, Response } from 'express';
2
+ import type { OxyServices } from '../../OxyServices';
3
+
4
+ const rateLimitMock = jest.fn();
5
+
6
+ jest.mock('express-rate-limit', () => ({
7
+ __esModule: true,
8
+ default: (...args: unknown[]) => rateLimitMock(...args),
9
+ }));
10
+
11
+ import { createOxyRateLimit } from '../rateLimit';
12
+
13
+ interface CapturedRateLimitOptions {
14
+ max: (req: Request) => number;
15
+ keyGenerator: (req: Request) => string;
16
+ skip: (req: Request) => boolean;
17
+ }
18
+
19
+ interface RateLimitTestRequest extends Request {
20
+ userId?: string | null;
21
+ user?: { id?: string; _id?: string } | null;
22
+ sessionId?: string | null;
23
+ serviceApp?: { appId?: string } | null;
24
+ serviceActingAs?: { userId?: string } | null;
25
+ observedMax?: number;
26
+ observedKey?: string;
27
+ }
28
+
29
+ function makeOxy(authHandler: RequestHandler): OxyServices {
30
+ return {
31
+ auth: jest.fn(() => authHandler),
32
+ } as unknown as OxyServices;
33
+ }
34
+
35
+ function makeRequest(overrides: Partial<RateLimitTestRequest> = {}): RateLimitTestRequest {
36
+ return {
37
+ method: 'GET',
38
+ path: '/api/test',
39
+ ip: '203.0.113.9',
40
+ socket: { remoteAddress: '203.0.113.9' },
41
+ ...overrides,
42
+ } as RateLimitTestRequest;
43
+ }
44
+
45
+ describe('@oxyhq/core/server rate limiter', () => {
46
+ beforeEach(() => {
47
+ rateLimitMock.mockImplementation((options: CapturedRateLimitOptions) => {
48
+ return (req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
49
+ req.observedMax = options.max(req);
50
+ req.observedKey = options.keyGenerator(req);
51
+ next();
52
+ };
53
+ });
54
+ });
55
+
56
+ afterEach(() => {
57
+ jest.clearAllMocks();
58
+ });
59
+
60
+ it('does not trust locally decoded non-session JWT identities for quota or bucket keys', () => {
61
+ const oxy = makeOxy((req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
62
+ req.userId = 'attacker-controlled-user';
63
+ req.user = { id: 'attacker-controlled-user' };
64
+ next();
65
+ });
66
+ const req = makeRequest();
67
+ const next = jest.fn();
68
+
69
+ createOxyRateLimit(oxy, { authenticatedMax: 5000, anonymousMax: 600 })(
70
+ req,
71
+ {} as Response,
72
+ next,
73
+ );
74
+
75
+ expect(req.observedMax).toBe(600);
76
+ expect(req.observedKey).toBe('203.0.113.9');
77
+ expect(next).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it('uses authenticated quota and per-user keys for server-validated sessions', () => {
81
+ const oxy = makeOxy((req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
82
+ req.userId = 'validated-user';
83
+ req.user = { id: 'validated-user' };
84
+ req.sessionId = 'validated-session';
85
+ next();
86
+ });
87
+ const req = makeRequest();
88
+
89
+ createOxyRateLimit(oxy, { authenticatedMax: 5000, anonymousMax: 600 })(
90
+ req,
91
+ {} as Response,
92
+ jest.fn(),
93
+ );
94
+
95
+ expect(req.observedMax).toBe(5000);
96
+ expect(req.observedKey).toBe('user:validated-user');
97
+ });
98
+
99
+ it('continues through the anonymous limiter if optional auth returns an error', () => {
100
+ const oxy = makeOxy((_req: Request, _res: Response, next: NextFunction) => {
101
+ next(new Error('token rejected'));
102
+ });
103
+ const req = makeRequest();
104
+ const next = jest.fn();
105
+
106
+ createOxyRateLimit(oxy, { authenticatedMax: 5000, anonymousMax: 600 })(
107
+ req,
108
+ {} as Response,
109
+ next,
110
+ );
111
+
112
+ expect(req.observedMax).toBe(600);
113
+ expect(req.observedKey).toBe('203.0.113.9');
114
+ expect(next).toHaveBeenCalledTimes(1);
115
+ });
116
+ });
@@ -11,10 +11,9 @@
11
11
  *
12
12
  * `createOxyCors` returns a self-contained Express middleware (no `cors`
13
13
  * package dependency) that:
14
- * - allows the Oxy apex origin family (anything under `*.${CENTRAL_IDP_APEX}`,
15
- * i.e. `oxy.so` covering `auth.oxy.so`, `api.oxy.so`, `accounts.oxy.so`,
16
- * `console.oxy.so`, `inbox.oxy.so`, the marketing site, …) reusing the
17
- * central-origin constants already in core, NOT a fresh hardcoded list,
14
+ * - allows the Oxy apex origin family over HTTPS only: the apex plus
15
+ * one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
16
+ * `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
18
17
  * - allows the caller's explicit `appOrigins`,
19
18
  * - DENIES everything else (no reflection, never a wildcard with credentials),
20
19
  * - echoes back the EXACT matched origin (so credentialed requests work) and
@@ -26,7 +25,6 @@
26
25
 
27
26
  import type { NextFunction, Request, RequestHandler, Response } from 'express';
28
27
  import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
29
- import { registrableApex } from '../utils/fapiAutoDetect';
30
28
 
31
29
  /** Default HTTP methods allowed across origins. */
32
30
  const DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
@@ -44,11 +42,16 @@ const DEFAULT_ALLOWED_HEADERS = [
44
42
  /** How long (seconds) a browser may cache a successful preflight. */
45
43
  const DEFAULT_MAX_AGE_SECONDS = 86_400;
46
44
 
45
+ const OXY_ONE_LABEL_SUBDOMAIN_PATTERN = new RegExp(
46
+ `^[a-z0-9-]+\\.${CENTRAL_IDP_APEX.replace('.', '\\.')}$`,
47
+ );
48
+
47
49
  export interface OxyCorsOptions {
48
50
  /**
49
51
  * Explicit additional allowed origins (exact-origin match, e.g.
50
52
  * `https://app.example.com`, `http://localhost:3000`). These are allowed IN
51
- * ADDITION TO the Oxy apex origin family. Each is normalized via `new URL().origin`.
53
+ * ADDITION TO the built-in HTTPS Oxy apex origin family. Each is normalized
54
+ * via `new URL().origin`.
52
55
  */
53
56
  appOrigins?: string[];
54
57
  /**
@@ -68,25 +71,27 @@ export interface OxyCorsOptions {
68
71
  }
69
72
 
70
73
  /**
71
- * Whether `candidate` belongs to the Oxy apex origin family — i.e. its
72
- * registrable apex equals {@link CENTRAL_IDP_APEX} (`oxy.so`). This matches the
73
- * apex itself (`https://oxy.so`) and any subdomain (`https://auth.oxy.so`,
74
- * `https://api.oxy.so`, …) over http or https, ports allowed. Returns false on
75
- * any parse failure (fail closed).
74
+ * Whether `candidate` belongs to the built-in Oxy apex origin family. This
75
+ * intentionally mirrors the API allowlist shape: HTTPS only, no custom port,
76
+ * the apex itself (`https://oxy.so`), or exactly one lowercase subdomain label
77
+ * (`https://auth.oxy.so`, `https://api.oxy.so`, …).
78
+ *
79
+ * Arbitrary/multi-level subdomains and `http://*.oxy.so` are not implicitly
80
+ * trusted for credentialed CORS. If a service needs a non-standard development
81
+ * or tenant origin, it must opt in explicitly via `appOrigins`.
76
82
  */
77
83
  function isOxyFamilyOrigin(candidate: string): boolean {
78
- let hostname: string;
79
- let protocol: string;
80
84
  try {
81
85
  const url = new URL(candidate);
82
- hostname = url.hostname.toLowerCase();
83
- protocol = url.protocol;
86
+ if (url.protocol !== 'https:' || url.port !== '') return false;
87
+
88
+ const hostname = url.hostname;
89
+ if (hostname === CENTRAL_IDP_APEX) return true;
90
+
91
+ return OXY_ONE_LABEL_SUBDOMAIN_PATTERN.test(hostname);
84
92
  } catch {
85
93
  return false;
86
94
  }
87
- if (protocol !== 'https:' && protocol !== 'http:') return false;
88
- if (hostname === CENTRAL_IDP_APEX) return true;
89
- return registrableApex(hostname) === CENTRAL_IDP_APEX;
90
95
  }
91
96
 
92
97
  /** Normalize a raw origin string to its canonical `scheme://host[:port]` form. */
@@ -99,8 +104,8 @@ function normalizeOrigin(raw: string): string | null {
99
104
  }
100
105
 
101
106
  /**
102
- * Build the origin-matching predicate: true iff `origin` is in the Oxy apex
103
- * family OR exactly matches one of the configured app origins.
107
+ * Build the origin-matching predicate: true iff `origin` is in the built-in
108
+ * HTTPS Oxy apex family OR exactly matches one of the configured app origins.
104
109
  */
105
110
  function buildOriginAllowed(appOrigins: string[]): (origin: string) => boolean {
106
111
  const explicit = new Set<string>();