@oxyhq/core 3.10.1 → 3.12.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 (92) 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 +189 -0
  8. package/dist/cjs/index.js +30 -4
  9. package/dist/cjs/mixins/OxyServices.assets.js +16 -1
  10. package/dist/cjs/mixins/OxyServices.auth.js +190 -1
  11. package/dist/cjs/mixins/OxyServices.civic.js +611 -0
  12. package/dist/cjs/mixins/OxyServices.identity.js +291 -0
  13. package/dist/cjs/mixins/OxyServices.sso.js +28 -1
  14. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  15. package/dist/cjs/mixins/index.js +6 -0
  16. package/dist/cjs/server/cors.js +20 -21
  17. package/dist/cjs/server/rateLimit.js +32 -8
  18. package/dist/cjs/utils/profileLinks.js +52 -0
  19. package/dist/cjs/utils/ssoReturn.js +1 -1
  20. package/dist/esm/.tsbuildinfo +1 -1
  21. package/dist/esm/AuthManager.js +9 -2
  22. package/dist/esm/HttpService.js +27 -9
  23. package/dist/esm/OxyServices.base.js +3 -2
  24. package/dist/esm/crypto/canonicalJson.js +104 -0
  25. package/dist/esm/crypto/keyManager.js +67 -8
  26. package/dist/esm/crypto/signatureService.js +187 -0
  27. package/dist/esm/index.js +19 -1
  28. package/dist/esm/mixins/OxyServices.assets.js +16 -1
  29. package/dist/esm/mixins/OxyServices.auth.js +190 -1
  30. package/dist/esm/mixins/OxyServices.civic.js +605 -0
  31. package/dist/esm/mixins/OxyServices.identity.js +287 -0
  32. package/dist/esm/mixins/OxyServices.sso.js +28 -1
  33. package/dist/esm/mixins/OxyServices.user.js +1 -0
  34. package/dist/esm/mixins/index.js +6 -0
  35. package/dist/esm/server/cors.js +20 -21
  36. package/dist/esm/server/rateLimit.js +32 -8
  37. package/dist/esm/utils/profileLinks.js +49 -0
  38. package/dist/esm/utils/ssoReturn.js +1 -1
  39. package/dist/types/.tsbuildinfo +1 -1
  40. package/dist/types/HttpService.d.ts +3 -0
  41. package/dist/types/OxyServices.d.ts +2 -2
  42. package/dist/types/crypto/canonicalJson.d.ts +44 -0
  43. package/dist/types/crypto/keyManager.d.ts +7 -0
  44. package/dist/types/crypto/signatureService.d.ts +112 -0
  45. package/dist/types/index.d.ts +10 -2
  46. package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
  47. package/dist/types/mixins/OxyServices.civic.d.ts +512 -0
  48. package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
  49. package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
  50. package/dist/types/mixins/index.d.ts +3 -1
  51. package/dist/types/models/interfaces.d.ts +3 -0
  52. package/dist/types/server/cors.d.ts +5 -5
  53. package/dist/types/utils/profileLinks.d.ts +36 -0
  54. package/dist/types/utils/ssoReturn.d.ts +1 -1
  55. package/package.json +2 -2
  56. package/src/AuthManager.ts +8 -2
  57. package/src/HttpService.ts +36 -8
  58. package/src/OxyServices.base.ts +3 -2
  59. package/src/OxyServices.ts +1 -1
  60. package/src/__tests__/authManager.security.test.ts +31 -0
  61. package/src/__tests__/httpServiceCsrf.test.ts +75 -0
  62. package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
  63. package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
  64. package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
  65. package/src/crypto/__tests__/signedRecord.test.ts +345 -0
  66. package/src/crypto/canonicalJson.ts +120 -0
  67. package/src/crypto/keyManager.ts +62 -12
  68. package/src/crypto/signatureService.ts +225 -0
  69. package/src/index.ts +55 -2
  70. package/src/mixins/OxyServices.assets.ts +16 -1
  71. package/src/mixins/OxyServices.auth.ts +309 -1
  72. package/src/mixins/OxyServices.civic.ts +956 -0
  73. package/src/mixins/OxyServices.identity.ts +445 -0
  74. package/src/mixins/OxyServices.sso.ts +30 -1
  75. package/src/mixins/OxyServices.user.ts +1 -0
  76. package/src/mixins/__tests__/OxyServices.civic.test.ts +1097 -0
  77. package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
  78. package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
  79. package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
  80. package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
  81. package/src/mixins/__tests__/sso.test.ts +31 -0
  82. package/src/mixins/index.ts +8 -0
  83. package/src/models/interfaces.ts +3 -0
  84. package/src/server/__tests__/cors.test.ts +5 -1
  85. package/src/server/__tests__/rateLimit.test.ts +116 -0
  86. package/src/server/cors.ts +25 -20
  87. package/src/server/rateLimit.ts +39 -8
  88. package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
  89. package/src/utils/__tests__/profileLinks.test.ts +126 -0
  90. package/src/utils/__tests__/ssoReturn.test.ts +1 -1
  91. package/src/utils/profileLinks.ts +74 -0
  92. package/src/utils/ssoReturn.ts +2 -2
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Identity Mixin tests.
3
+ *
4
+ * Stubs `makeRequest` so the tests run with no network. We assert request shape
5
+ * (method, URL, body, cache options), response unwrapping, the DID derivation,
6
+ * the EXACT signed payload `linkIdentityKey` produces (it must match the
7
+ * server's `JSON.stringify({ action, userId, timestamp })` reconstruction
8
+ * byte-for-byte), and the cache sweep on every mutation.
9
+ */
10
+
11
+ import type { AuthMethodsResponse, DidDocument, SignedRecordEnvelope, VerifiedDomain } from '@oxyhq/contracts';
12
+ import { OxyServices } from '../../OxyServices';
13
+ import { KeyManager } from '../../crypto/keyManager';
14
+ import { SignatureService } from '../../crypto/signatureService';
15
+
16
+ const didDocFixture: DidDocument = {
17
+ '@context': ['https://www.w3.org/ns/did/v1'],
18
+ id: 'did:web:oxy.so:u:user-123',
19
+ controller: ['did:web:oxy.so:u:user-123', 'did:web:oxy.so'],
20
+ verificationMethod: [],
21
+ authentication: [],
22
+ assertionMethod: [],
23
+ alsoKnownAs: ['acct:nate@oxy.so'],
24
+ service: [],
25
+ };
26
+
27
+ const authMethodsFixture: AuthMethodsResponse = {
28
+ did: 'did:web:oxy.so:u:user-123',
29
+ methods: [{ type: 'identity', linkedAt: '2026-06-26T00:00:00.000Z', verificationMethodId: '#key-1' }],
30
+ };
31
+
32
+ const recordFixture: SignedRecordEnvelope = {
33
+ version: 1,
34
+ type: 'profile',
35
+ subject: 'did:web:oxy.so:u:user-123',
36
+ issuer: 'did:web:oxy.so:u:user-123',
37
+ record: { displayName: 'Nate' },
38
+ issuedAt: 1700000000000,
39
+ publicKey: 'pub-hex',
40
+ alg: 'ES256K-DER-SHA256',
41
+ signature: 'sig-hex',
42
+ };
43
+
44
+ const domainFixture: VerifiedDomain = {
45
+ domain: 'nate.com',
46
+ verifiedAt: '2026-06-26T00:00:00.000Z',
47
+ method: 'dns-txt',
48
+ };
49
+
50
+ describe('OxyServices.identity', () => {
51
+ let oxy: OxyServices;
52
+ let makeRequestSpy: jest.SpyInstance;
53
+ let clearPrefixSpy: jest.SpyInstance;
54
+ let clearEntrySpy: jest.SpyInstance;
55
+
56
+ beforeEach(() => {
57
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
58
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
59
+ clearPrefixSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
60
+ clearEntrySpy = jest.spyOn(oxy, 'clearCacheEntry').mockReturnValue(undefined);
61
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue('user-123');
62
+ });
63
+
64
+ afterEach(() => {
65
+ jest.restoreAllMocks();
66
+ });
67
+
68
+ describe('resolveDid', () => {
69
+ it('GETs the DID document and caches the read', async () => {
70
+ makeRequestSpy.mockResolvedValue(didDocFixture);
71
+
72
+ const result = await oxy.resolveDid('user-123');
73
+
74
+ expect(result).toEqual(didDocFixture);
75
+ expect(makeRequestSpy).toHaveBeenCalledWith(
76
+ 'GET',
77
+ '/u/user-123/did.json',
78
+ undefined,
79
+ expect.objectContaining({ cache: true }),
80
+ );
81
+ });
82
+
83
+ it('URL-encodes the userId path segment', async () => {
84
+ makeRequestSpy.mockResolvedValue(didDocFixture);
85
+ await oxy.resolveDid('a/b');
86
+ expect(makeRequestSpy).toHaveBeenCalledWith(
87
+ 'GET',
88
+ '/u/a%2Fb/did.json',
89
+ undefined,
90
+ expect.anything(),
91
+ );
92
+ });
93
+ });
94
+
95
+ describe('getMyDid / getMyDidDocument', () => {
96
+ it('derives the current user DID', () => {
97
+ expect(oxy.getMyDid()).toBe('did:web:oxy.so:u:user-123');
98
+ });
99
+
100
+ it('throws when no user is authenticated', () => {
101
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
102
+ expect(() => oxy.getMyDid()).toThrow(/No authenticated user/);
103
+ });
104
+
105
+ it('resolves the current user DID document', async () => {
106
+ makeRequestSpy.mockResolvedValue(didDocFixture);
107
+ const result = await oxy.getMyDidDocument();
108
+ expect(result).toEqual(didDocFixture);
109
+ expect(makeRequestSpy).toHaveBeenCalledWith(
110
+ 'GET',
111
+ '/u/user-123/did.json',
112
+ undefined,
113
+ expect.objectContaining({ cache: true }),
114
+ );
115
+ });
116
+ });
117
+
118
+ describe('listAuthMethods', () => {
119
+ it('GETs /auth/methods', async () => {
120
+ makeRequestSpy.mockResolvedValue(authMethodsFixture);
121
+ const result = await oxy.listAuthMethods();
122
+ expect(result).toEqual(authMethodsFixture);
123
+ expect(makeRequestSpy).toHaveBeenCalledWith(
124
+ 'GET',
125
+ '/auth/methods',
126
+ undefined,
127
+ expect.objectContaining({ cache: true }),
128
+ );
129
+ });
130
+ });
131
+
132
+ describe('linkIdentityKey', () => {
133
+ it('signs the exact server-expected payload and POSTs /auth/link', async () => {
134
+ jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
135
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue('pub-hex');
136
+ const signSpy = jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
137
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'Identity linked successfully' });
138
+
139
+ const result = await oxy.linkIdentityKey();
140
+
141
+ // The signed message MUST match the server's reconstruction byte-for-byte:
142
+ // JSON.stringify({ action, userId, timestamp }) in that key order.
143
+ const expectedMessage = JSON.stringify({
144
+ action: 'link_identity',
145
+ userId: 'user-123',
146
+ timestamp: 1700000000000,
147
+ });
148
+ expect(signSpy).toHaveBeenCalledWith(expectedMessage);
149
+ expect(expectedMessage).toBe(
150
+ '{"action":"link_identity","userId":"user-123","timestamp":1700000000000}',
151
+ );
152
+
153
+ expect(makeRequestSpy).toHaveBeenCalledWith(
154
+ 'POST',
155
+ '/auth/link',
156
+ { type: 'identity', publicKey: 'pub-hex', signature: 'sig-hex', timestamp: 1700000000000 },
157
+ expect.objectContaining({ cache: false }),
158
+ );
159
+ expect(result).toEqual({ success: true, message: 'Identity linked successfully' });
160
+ });
161
+
162
+ it('sweeps /users/me, auth methods, domains and the DID cache after linking', async () => {
163
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue('pub-hex');
164
+ jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
165
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'ok' });
166
+
167
+ await oxy.linkIdentityKey();
168
+
169
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
170
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
171
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/identity/domains');
172
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
173
+ });
174
+
175
+ it('throws (no network) when the device has no identity', async () => {
176
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
177
+ await expect(oxy.linkIdentityKey()).rejects.toThrow(/No identity found/);
178
+ expect(makeRequestSpy).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it('throws when no user is authenticated', async () => {
182
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
183
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue('pub-hex');
184
+ await expect(oxy.linkIdentityKey()).rejects.toThrow(/No authenticated user/);
185
+ expect(makeRequestSpy).not.toHaveBeenCalled();
186
+ });
187
+ });
188
+
189
+ describe('linkPassword', () => {
190
+ it('POSTs /auth/link with the password method and sweeps cache', async () => {
191
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'Password auth linked successfully' });
192
+
193
+ await oxy.linkPassword('nate@oxy.so', 'sup3r-secret');
194
+
195
+ expect(makeRequestSpy).toHaveBeenCalledWith(
196
+ 'POST',
197
+ '/auth/link',
198
+ { type: 'password', email: 'nate@oxy.so', password: 'sup3r-secret' },
199
+ expect.objectContaining({ cache: false }),
200
+ );
201
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
202
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
203
+ });
204
+ });
205
+
206
+ describe('unlinkAuthMethod', () => {
207
+ it('DELETEs /auth/link/:type and sweeps cache', async () => {
208
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'identity auth unlinked successfully' });
209
+
210
+ await oxy.unlinkAuthMethod('identity');
211
+
212
+ expect(makeRequestSpy).toHaveBeenCalledWith(
213
+ 'DELETE',
214
+ '/auth/link/identity',
215
+ undefined,
216
+ expect.objectContaining({ cache: false }),
217
+ );
218
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
219
+ });
220
+ });
221
+
222
+ describe('signRecord (client-only)', () => {
223
+ it('signs with the current user DID as subject and does not hit the network', async () => {
224
+ const signRecordSpy = jest
225
+ .spyOn(SignatureService, 'signRecord')
226
+ .mockResolvedValue(recordFixture);
227
+
228
+ const result = await oxy.signRecord('profile', { displayName: 'Nate' });
229
+
230
+ expect(signRecordSpy).toHaveBeenCalledWith('profile', 'did:web:oxy.so:u:user-123', {
231
+ displayName: 'Nate',
232
+ });
233
+ expect(result).toEqual(recordFixture);
234
+ expect(makeRequestSpy).not.toHaveBeenCalled();
235
+ });
236
+ });
237
+
238
+ describe('publishRecord', () => {
239
+ it('signs then POSTs the envelope to /identity/records', async () => {
240
+ jest.spyOn(SignatureService, 'signRecord').mockResolvedValue(recordFixture);
241
+ makeRequestSpy.mockResolvedValue({ envelope: recordFixture, verified: true });
242
+
243
+ const result = await oxy.publishRecord('profile', { displayName: 'Nate' });
244
+
245
+ expect(makeRequestSpy).toHaveBeenCalledWith(
246
+ 'POST',
247
+ '/identity/records',
248
+ recordFixture,
249
+ expect.objectContaining({ cache: false }),
250
+ );
251
+ expect(result).toEqual({ envelope: recordFixture, verified: true });
252
+ });
253
+ });
254
+
255
+ describe('getRecord / verifyRecord', () => {
256
+ it('GETs and unwraps the record', async () => {
257
+ makeRequestSpy.mockResolvedValue({ record: recordFixture });
258
+ const result = await oxy.getRecord('user-123', 'profile');
259
+ expect(result).toEqual(recordFixture);
260
+ expect(makeRequestSpy).toHaveBeenCalledWith(
261
+ 'GET',
262
+ '/identity/records/user-123/profile',
263
+ undefined,
264
+ expect.objectContaining({ cache: true }),
265
+ );
266
+ });
267
+
268
+ it('GETs the verify verdict', async () => {
269
+ makeRequestSpy.mockResolvedValue({ verified: true });
270
+ const result = await oxy.verifyRecord('user-123', 'identity');
271
+ expect(result).toEqual({ verified: true });
272
+ expect(makeRequestSpy).toHaveBeenCalledWith(
273
+ 'GET',
274
+ '/identity/records/user-123/identity/verify',
275
+ undefined,
276
+ expect.objectContaining({ cache: true }),
277
+ );
278
+ });
279
+ });
280
+
281
+ describe('exportMyData', () => {
282
+ it('GETs /users/me/export without caching', async () => {
283
+ const bundle = { $schema: 'x', did: 'did:web:oxy.so:u:user-123' };
284
+ makeRequestSpy.mockResolvedValue(bundle);
285
+ const result = await oxy.exportMyData();
286
+ expect(result).toEqual(bundle);
287
+ expect(makeRequestSpy).toHaveBeenCalledWith(
288
+ 'GET',
289
+ '/users/me/export',
290
+ undefined,
291
+ expect.objectContaining({ cache: false }),
292
+ );
293
+ });
294
+ });
295
+
296
+ describe('domains', () => {
297
+ it('requestDomainVerification POSTs the domain', async () => {
298
+ const instructions = {
299
+ domain: 'nate.com',
300
+ token: 'tok',
301
+ dns: { name: '_oxy-identity.nate.com', value: 'oxy-domain-verification=tok' },
302
+ wellKnown: { url: 'https://nate.com/.well-known/oxy-domain', body: 'tok' },
303
+ };
304
+ makeRequestSpy.mockResolvedValue(instructions);
305
+
306
+ const result = await oxy.requestDomainVerification('nate.com');
307
+
308
+ expect(result).toEqual(instructions);
309
+ expect(makeRequestSpy).toHaveBeenCalledWith(
310
+ 'POST',
311
+ '/identity/domains',
312
+ { domain: 'nate.com' },
313
+ expect.objectContaining({ cache: false }),
314
+ );
315
+ });
316
+
317
+ it('verifyDomain POSTs to the verify path and sweeps cache', async () => {
318
+ makeRequestSpy.mockResolvedValue({ verified: true, domain: domainFixture });
319
+
320
+ await oxy.verifyDomain('nate.com');
321
+
322
+ expect(makeRequestSpy).toHaveBeenCalledWith(
323
+ 'POST',
324
+ '/identity/domains/nate.com/verify',
325
+ undefined,
326
+ expect.objectContaining({ cache: false }),
327
+ );
328
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
329
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
330
+ });
331
+
332
+ it('listDomains GETs and unwraps domains', async () => {
333
+ makeRequestSpy.mockResolvedValue({ domains: [domainFixture] });
334
+ const result = await oxy.listDomains();
335
+ expect(result).toEqual([domainFixture]);
336
+ expect(makeRequestSpy).toHaveBeenCalledWith(
337
+ 'GET',
338
+ '/identity/domains',
339
+ undefined,
340
+ expect.objectContaining({ cache: true }),
341
+ );
342
+ });
343
+
344
+ it('listDomains defaults to an empty array when domains is absent', async () => {
345
+ makeRequestSpy.mockResolvedValue({});
346
+ await expect(oxy.listDomains()).resolves.toEqual([]);
347
+ });
348
+
349
+ it('removeDomain DELETEs and sweeps cache', async () => {
350
+ makeRequestSpy.mockResolvedValue({ success: true });
351
+
352
+ await oxy.removeDomain('nate.com');
353
+
354
+ expect(makeRequestSpy).toHaveBeenCalledWith(
355
+ 'DELETE',
356
+ '/identity/domains/nate.com',
357
+ undefined,
358
+ expect.objectContaining({ cache: false }),
359
+ );
360
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
361
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
362
+ });
363
+ });
364
+ });
@@ -0,0 +1,47 @@
1
+ import { OxyServices } from '../../OxyServices';
2
+
3
+ interface FetchCall {
4
+ url: string;
5
+ init: RequestInit | undefined;
6
+ }
7
+
8
+ describe('asset fetch credentials', () => {
9
+ const originalFetch = globalThis.fetch;
10
+
11
+ afterEach(() => {
12
+ globalThis.fetch = originalFetch;
13
+ jest.restoreAllMocks();
14
+ });
15
+
16
+ it('omits credentials for arbitrary asset content URLs', async () => {
17
+ const calls: FetchCall[] = [];
18
+ globalThis.fetch = async (input, init) => {
19
+ calls.push({ url: String(input), init });
20
+ return new Response('asset-body', { status: 200 });
21
+ };
22
+
23
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
24
+
25
+ await oxy.fetchAssetContent('https://attacker.oxy.so/cdn/object.txt', 'text');
26
+
27
+ expect(calls).toHaveLength(1);
28
+ expect(calls[0].url).toBe('https://attacker.oxy.so/cdn/object.txt');
29
+ expect(calls[0].init?.credentials).toBe('omit');
30
+ });
31
+
32
+ it('includes credentials for asset content fetched from the configured API origin', async () => {
33
+ const calls: FetchCall[] = [];
34
+ globalThis.fetch = async (input, init) => {
35
+ calls.push({ url: String(input), init });
36
+ return new Response('asset-body', { status: 200 });
37
+ };
38
+
39
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
40
+
41
+ await oxy.fetchAssetContent('https://api.oxy.so/assets/private-file/stream', 'text');
42
+
43
+ expect(calls).toHaveLength(1);
44
+ expect(calls[0].url).toBe('https://api.oxy.so/assets/private-file/stream');
45
+ expect(calls[0].init?.credentials).toBe('include');
46
+ });
47
+ });
@@ -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
+ });