@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +9 -2
- package/dist/cjs/HttpService.js +27 -9
- package/dist/cjs/OxyServices.base.js +3 -2
- package/dist/cjs/crypto/canonicalJson.js +107 -0
- package/dist/cjs/crypto/keyManager.js +67 -8
- package/dist/cjs/crypto/signatureService.js +103 -0
- package/dist/cjs/i18n/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/es-ES.json +9 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
- package/dist/cjs/index.js +15 -5
- package/dist/cjs/mixins/OxyServices.assets.js +45 -7
- package/dist/cjs/mixins/OxyServices.auth.js +190 -1
- package/dist/cjs/mixins/OxyServices.identity.js +291 -0
- package/dist/cjs/mixins/OxyServices.sso.js +28 -1
- package/dist/cjs/mixins/OxyServices.user.js +1 -0
- package/dist/cjs/mixins/OxyServices.utility.js +52 -23
- package/dist/cjs/mixins/index.js +3 -0
- package/dist/cjs/server/cors.js +20 -21
- package/dist/cjs/server/rateLimit.js +32 -8
- package/dist/cjs/utils/fapiAutoDetect.js +12 -42
- package/dist/cjs/utils/ssoReturn.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +9 -2
- package/dist/esm/HttpService.js +27 -9
- package/dist/esm/OxyServices.base.js +3 -2
- package/dist/esm/crypto/canonicalJson.js +104 -0
- package/dist/esm/crypto/keyManager.js +67 -8
- package/dist/esm/crypto/signatureService.js +102 -0
- package/dist/esm/i18n/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/es-ES.json +9 -0
- package/dist/esm/i18n/locales/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
- package/dist/esm/index.js +10 -2
- package/dist/esm/mixins/OxyServices.assets.js +45 -7
- package/dist/esm/mixins/OxyServices.auth.js +190 -1
- package/dist/esm/mixins/OxyServices.identity.js +287 -0
- package/dist/esm/mixins/OxyServices.sso.js +28 -1
- package/dist/esm/mixins/OxyServices.user.js +1 -0
- package/dist/esm/mixins/OxyServices.utility.js +52 -23
- package/dist/esm/mixins/index.js +3 -0
- package/dist/esm/server/cors.js +20 -21
- package/dist/esm/server/rateLimit.js +32 -8
- package/dist/esm/utils/fapiAutoDetect.js +12 -41
- package/dist/esm/utils/ssoReturn.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -0
- package/dist/types/OxyServices.d.ts +2 -2
- package/dist/types/crypto/canonicalJson.d.ts +44 -0
- package/dist/types/crypto/keyManager.d.ts +7 -0
- package/dist/types/crypto/signatureService.d.ts +61 -0
- package/dist/types/index.d.ts +7 -3
- package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +3 -0
- package/dist/types/server/cors.d.ts +5 -5
- package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
- package/dist/types/utils/ssoReturn.d.ts +1 -1
- package/package.json +3 -2
- package/src/AuthManager.ts +8 -2
- package/src/HttpService.ts +36 -8
- package/src/OxyServices.base.ts +3 -2
- package/src/OxyServices.ts +1 -1
- package/src/__tests__/authManager.security.test.ts +31 -0
- package/src/__tests__/authSocket.test.ts +96 -0
- package/src/__tests__/httpServiceCsrf.test.ts +75 -0
- package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
- package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
- package/src/crypto/__tests__/signedRecord.test.ts +125 -0
- package/src/crypto/canonicalJson.ts +120 -0
- package/src/crypto/keyManager.ts +62 -12
- package/src/crypto/signatureService.ts +126 -0
- package/src/i18n/locales/en-US.json +9 -0
- package/src/i18n/locales/es-ES.json +9 -0
- package/src/index.ts +28 -3
- package/src/mixins/OxyServices.assets.ts +56 -7
- package/src/mixins/OxyServices.auth.ts +309 -1
- package/src/mixins/OxyServices.identity.ts +445 -0
- package/src/mixins/OxyServices.sso.ts +30 -1
- package/src/mixins/OxyServices.user.ts +1 -0
- package/src/mixins/OxyServices.utility.ts +57 -23
- package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/assetUpload.test.ts +191 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +49 -2
- package/src/mixins/__tests__/sso.test.ts +31 -0
- package/src/mixins/index.ts +4 -0
- package/src/models/interfaces.ts +3 -0
- package/src/server/__tests__/cors.test.ts +5 -1
- package/src/server/__tests__/rateLimit.test.ts +116 -0
- package/src/server/cors.ts +25 -20
- package/src/server/rateLimit.ts +39 -8
- package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
- package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
- package/src/utils/__tests__/ssoReturn.test.ts +1 -1
- package/src/utils/fapiAutoDetect.ts +12 -39
- 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
|
|
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);
|
package/src/mixins/index.ts
CHANGED
|
@@ -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
|
package/src/models/interfaces.ts
CHANGED
|
@@ -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 +
|
|
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
|
+
});
|
package/src/server/cors.ts
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
* `console.oxy.so`, `inbox.oxy.so`,
|
|
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
|
|
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
|
|
72
|
-
*
|
|
73
|
-
* apex itself (`https://oxy.so`)
|
|
74
|
-
* `https://api.oxy.so`, …)
|
|
75
|
-
*
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
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>();
|