@oxyhq/core 5.1.0 → 5.2.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 +27 -5
- package/dist/cjs/OxyServices.base.js +71 -0
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/mixins/OxyServices.accounts.js +10 -25
- package/dist/cjs/mixins/OxyServices.assets.js +76 -0
- package/dist/cjs/mixins/OxyServices.user.js +30 -11
- package/dist/cjs/utils/ssoBounce.js +59 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +27 -5
- package/dist/esm/OxyServices.base.js +71 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.accounts.js +10 -25
- package/dist/esm/mixins/OxyServices.assets.js +76 -0
- package/dist/esm/mixins/OxyServices.user.js +30 -11
- package/dist/esm/utils/ssoBounce.js +57 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.base.d.ts +38 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.accounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +36 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +1 -0
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +1 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +1 -0
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.links.d.ts +1 -0
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.nodes.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.reputation.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.silent.d.ts +1 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +34 -10
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/dist/types/models/interfaces.d.ts +23 -0
- package/dist/types/utils/ssoBounce.d.ts +47 -0
- package/package.json +1 -1
- package/src/AuthManager.ts +29 -5
- package/src/OxyServices.base.ts +79 -0
- package/src/__tests__/establishDeviceRefreshSlot.test.ts +221 -0
- package/src/index.ts +3 -0
- package/src/mixins/OxyServices.accounts.ts +10 -33
- package/src/mixins/OxyServices.assets.ts +92 -1
- package/src/mixins/OxyServices.user.ts +43 -11
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadataBySha.test.ts +135 -0
- package/src/mixins/__tests__/getUsersByIds.test.ts +149 -0
- package/src/models/interfaces.ts +24 -0
- package/src/utils/__tests__/ssoBounce.test.ts +41 -0
- package/src/utils/ssoBounce.ts +61 -0
package/src/OxyServices.base.ts
CHANGED
|
@@ -9,6 +9,9 @@ import { handleHttpError } from './utils/errorUtils';
|
|
|
9
9
|
import { HttpService, type AuthRefreshReason, type RequestOptions } from './HttpService';
|
|
10
10
|
import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
|
|
11
11
|
import { resolveCentralAuthUrl } from './utils/authWebUrl';
|
|
12
|
+
import { isWeb } from './utils/platform';
|
|
13
|
+
import { registrableApex } from './utils/fapiAutoDetect';
|
|
14
|
+
import { logger } from './utils/loggerUtils';
|
|
12
15
|
|
|
13
16
|
export interface OxyConfig extends OxyConfigBase {
|
|
14
17
|
cloudURL?: string;
|
|
@@ -310,6 +313,82 @@ export class OxyServicesBase {
|
|
|
310
313
|
return this.httpService.getAccessToken();
|
|
311
314
|
}
|
|
312
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Register the CURRENTLY-ACTIVE session in the device's first-party
|
|
318
|
+
* multi-account refresh-cookie set by calling `POST /auth/session`.
|
|
319
|
+
*
|
|
320
|
+
* This is the single, shared primitive every web primary-session commit and the
|
|
321
|
+
* account switch use to plant their `oxy_rt_<authuser>` slot. It MUST be a
|
|
322
|
+
* dedicated call to `/auth/session` rather than relying on whichever endpoint
|
|
323
|
+
* established the session: that endpoint is frequently OUTSIDE the cookie's
|
|
324
|
+
* `Path=/auth` scope (`/accounts/:id/switch`) or is a cross-origin/credential-
|
|
325
|
+
* less restore (`/sso/exchange`, the IdP `/auth/silent` postMessage) that cannot
|
|
326
|
+
* set an `api.oxy.so` cookie at all. `/auth/session` runs where the device's
|
|
327
|
+
* existing slots ARE visible, so the server resolves this user's slot (reusing an
|
|
328
|
+
* existing one or allocating a new one) without clobbering a sibling account,
|
|
329
|
+
* mints a fresh access token bound to the same session, and returns the resolved
|
|
330
|
+
* `authuser`.
|
|
331
|
+
*
|
|
332
|
+
* Behaviour:
|
|
333
|
+
* - Requires a planted bearer (the caller must have already installed the
|
|
334
|
+
* session's access token); `/auth/session` derives the session from it.
|
|
335
|
+
* - On success re-plants the rotated access token (so the active token matches
|
|
336
|
+
* the freshly-rotated cookie) and returns the device `authuser` slot.
|
|
337
|
+
* - WEB-ONLY: on native there are no first-party refresh cookies → returns
|
|
338
|
+
* `null`.
|
|
339
|
+
* - FIRST-PARTY-ONLY: the cookie is host-only on the API host with
|
|
340
|
+
* `SameSite=Lax`, so it only sticks when the page is SAME-SITE (same
|
|
341
|
+
* registrable apex) as the API. On a cross-apex RP (`mention.earth` calling
|
|
342
|
+
* `api.oxy.so`) the browser rejects the `Set-Cookie` as a third-party cookie,
|
|
343
|
+
* so a returned slot would be a phantom never enumerated by `refresh-all`.
|
|
344
|
+
* Those RPs durably restore via the per-apex `/auth/silent` iframe + `/sso`
|
|
345
|
+
* bounce, NOT this device set → returns `null` without calling the API.
|
|
346
|
+
* - BEST-EFFORT: a failure (e.g. transient network) never throws — the session
|
|
347
|
+
* stays active in-memory; only its reload durability via the device set is at
|
|
348
|
+
* risk. The caller treats `null` as "not registered in the device set".
|
|
349
|
+
*
|
|
350
|
+
* @returns The resolved device `authuser` slot, or `null` on native / cross-apex
|
|
351
|
+
* / failure.
|
|
352
|
+
*/
|
|
353
|
+
public async establishDeviceRefreshSlot(): Promise<number | null> {
|
|
354
|
+
if (!isWeb()) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
if (typeof window !== 'undefined' && window.location?.hostname) {
|
|
358
|
+
const pageApex = registrableApex(window.location.hostname);
|
|
359
|
+
let apiApex: string | null = null;
|
|
360
|
+
try {
|
|
361
|
+
apiApex = registrableApex(new URL(this.getBaseURL()).hostname);
|
|
362
|
+
} catch {
|
|
363
|
+
apiApex = null;
|
|
364
|
+
}
|
|
365
|
+
if (!pageApex || !apiApex || pageApex !== apiApex) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
const established = await this.makeRequest<{ accessToken?: string; authuser?: number }>(
|
|
371
|
+
'POST',
|
|
372
|
+
'/auth/session',
|
|
373
|
+
undefined,
|
|
374
|
+
{ cache: false },
|
|
375
|
+
);
|
|
376
|
+
// `/auth/session` mints a fresh access token off the same session; re-plant
|
|
377
|
+
// it so the active token matches the rotated cookie.
|
|
378
|
+
if (established?.accessToken) {
|
|
379
|
+
this.setTokens(established.accessToken);
|
|
380
|
+
}
|
|
381
|
+
return typeof established?.authuser === 'number' ? established.authuser : null;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
logger.warn(
|
|
384
|
+
'[OxyServices] Failed to establish device refresh cookie via POST /auth/session; the session is active in-session but may not survive a reload as part of the device account set',
|
|
385
|
+
{ component: 'OxyServices', method: 'establishDeviceRefreshSlot' },
|
|
386
|
+
error,
|
|
387
|
+
);
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
313
392
|
/**
|
|
314
393
|
* Decode the current access token and return its `exp` claim in SECONDS since
|
|
315
394
|
* the Unix epoch (the raw JWT `exp` unit), or `null` when there is no token,
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `establishDeviceRefreshSlot` + primary-session slot-registration tests.
|
|
3
|
+
*
|
|
4
|
+
* ROOT CAUSE these lock in: a web PRIMARY session (FedCM exchange, central `/sso`
|
|
5
|
+
* return, IdP `/auth/silent` iframe, password) plants only an access token. The
|
|
6
|
+
* cross-origin/credential-less restore endpoints (`/sso/exchange`, `/auth/silent`)
|
|
7
|
+
* cannot set the device's `oxy_rt_<authuser>` cookie, so without an explicit
|
|
8
|
+
* `POST /auth/session` the primary never joins the device refresh-cookie set:
|
|
9
|
+
* `/auth/refresh-all` returns zero accounts and account-switch persistence has no
|
|
10
|
+
* foundation. `establishDeviceRefreshSlot()` is the single shared primitive (on the
|
|
11
|
+
* base, reused by `switchToAccount` and `AuthManager.handleAuthSuccess`) that plants
|
|
12
|
+
* the slot where the cookie is visible, re-plants the rotated token, and returns the
|
|
13
|
+
* authoritative `authuser`.
|
|
14
|
+
*
|
|
15
|
+
* `testEnvironment` is `node`, where `getPlatformOS()` resolves to `'web'`
|
|
16
|
+
* (`isWeb()` is true) and `window` is undefined — so the FIRST-PARTY apex gate is
|
|
17
|
+
* inert and the method exercises its happy path. Browser-only apex gating is
|
|
18
|
+
* verified separately by stubbing a `window`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { OxyServices } from '../OxyServices';
|
|
22
|
+
import { AuthManager } from '../AuthManager';
|
|
23
|
+
import type { StorageAdapter } from '../AuthManager';
|
|
24
|
+
import type { RefreshAllResponse, RefreshCookieResponse, User } from '../models/interfaces';
|
|
25
|
+
import type { SessionLoginResponse } from '../models/session';
|
|
26
|
+
|
|
27
|
+
function buildAccessToken(claims: Record<string, unknown>): string {
|
|
28
|
+
const b64url = (value: string): string =>
|
|
29
|
+
Buffer.from(value).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
30
|
+
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
31
|
+
const payload = b64url(JSON.stringify(claims));
|
|
32
|
+
return `${header}.${payload}.signature`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('OxyServices.establishDeviceRefreshSlot', () => {
|
|
36
|
+
let oxy: OxyServices;
|
|
37
|
+
let makeRequestSpy: jest.SpyInstance;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
41
|
+
oxy.httpService.setTokens('primary-token');
|
|
42
|
+
makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
makeRequestSpy.mockRestore();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('POSTs to /auth/session, re-plants the rotated access token, and returns the server authuser', async () => {
|
|
50
|
+
makeRequestSpy.mockResolvedValue({ accessToken: 'rotated-token', authuser: 2 });
|
|
51
|
+
|
|
52
|
+
const authuser = await oxy.establishDeviceRefreshSlot();
|
|
53
|
+
|
|
54
|
+
expect(authuser).toBe(2);
|
|
55
|
+
expect(makeRequestSpy).toHaveBeenCalledWith('POST', '/auth/session', undefined, { cache: false });
|
|
56
|
+
// The rotated token from /auth/session is now the active token.
|
|
57
|
+
expect(oxy.getAccessToken()).toBe('rotated-token');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns the authuser even when the response carries no rotated token (keeps the existing token)', async () => {
|
|
61
|
+
makeRequestSpy.mockResolvedValue({ authuser: 0 });
|
|
62
|
+
|
|
63
|
+
const authuser = await oxy.establishDeviceRefreshSlot();
|
|
64
|
+
|
|
65
|
+
expect(authuser).toBe(0);
|
|
66
|
+
expect(oxy.getAccessToken()).toBe('primary-token');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns null when the server omits a numeric authuser', async () => {
|
|
70
|
+
makeRequestSpy.mockResolvedValue({ accessToken: 'rotated-token' });
|
|
71
|
+
|
|
72
|
+
const authuser = await oxy.establishDeviceRefreshSlot();
|
|
73
|
+
|
|
74
|
+
expect(authuser).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('is best-effort: a failed /auth/session resolves null and never throws', async () => {
|
|
78
|
+
makeRequestSpy.mockRejectedValue(new Error('network down'));
|
|
79
|
+
|
|
80
|
+
await expect(oxy.establishDeviceRefreshSlot()).resolves.toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('FIRST-PARTY gate: skips /auth/session when the page apex differs from the API apex (cross-apex RP)', async () => {
|
|
84
|
+
// Simulate a cross-apex RP page (mention.earth) calling api.oxy.so.
|
|
85
|
+
const rp = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
86
|
+
rp.httpService.setTokens('primary-token');
|
|
87
|
+
const rpSpy = jest.spyOn(rp, 'makeRequest');
|
|
88
|
+
const originalWindow = (globalThis as { window?: unknown }).window;
|
|
89
|
+
(globalThis as { window?: unknown }).window = { location: { hostname: 'mention.earth' } };
|
|
90
|
+
try {
|
|
91
|
+
const authuser = await rp.establishDeviceRefreshSlot();
|
|
92
|
+
expect(authuser).toBeNull();
|
|
93
|
+
expect(rpSpy).not.toHaveBeenCalled();
|
|
94
|
+
} finally {
|
|
95
|
+
if (originalWindow === undefined) {
|
|
96
|
+
delete (globalThis as { window?: unknown }).window;
|
|
97
|
+
} else {
|
|
98
|
+
(globalThis as { window?: unknown }).window = originalWindow;
|
|
99
|
+
}
|
|
100
|
+
rpSpy.mockRestore();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('FIRST-PARTY gate: proceeds when the page apex matches the API apex (first-party *.oxy.so)', async () => {
|
|
105
|
+
const fp = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
106
|
+
fp.httpService.setTokens('primary-token');
|
|
107
|
+
const fpSpy = jest.spyOn(fp, 'makeRequest').mockResolvedValue({ accessToken: 'rotated', authuser: 1 });
|
|
108
|
+
const originalWindow = (globalThis as { window?: unknown }).window;
|
|
109
|
+
(globalThis as { window?: unknown }).window = { location: { hostname: 'accounts.oxy.so' } };
|
|
110
|
+
try {
|
|
111
|
+
const authuser = await fp.establishDeviceRefreshSlot();
|
|
112
|
+
expect(authuser).toBe(1);
|
|
113
|
+
expect(fpSpy).toHaveBeenCalledWith('POST', '/auth/session', undefined, { cache: false });
|
|
114
|
+
} finally {
|
|
115
|
+
if (originalWindow === undefined) {
|
|
116
|
+
delete (globalThis as { window?: unknown }).window;
|
|
117
|
+
} else {
|
|
118
|
+
(globalThis as { window?: unknown }).window = originalWindow;
|
|
119
|
+
}
|
|
120
|
+
fpSpy.mockRestore();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// AuthManager.handleAuthSuccess — every web primary now joins the device set
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
class InMemoryStorage implements StorageAdapter {
|
|
130
|
+
private store = new Map<string, string>();
|
|
131
|
+
getItem(key: string): string | null { return this.store.get(key) ?? null; }
|
|
132
|
+
setItem(key: string, value: string): void { this.store.set(key, value); }
|
|
133
|
+
removeItem(key: string): void { this.store.delete(key); }
|
|
134
|
+
raw(): Map<string, string> { return this.store; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface MockHttpService {
|
|
138
|
+
setTokens: jest.Mock;
|
|
139
|
+
setAuthRefreshHandler: jest.Mock;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface MockServices {
|
|
143
|
+
establishDeviceRefreshSlot: jest.Mock<Promise<number | null>, []>;
|
|
144
|
+
getAccessToken: jest.Mock<string | null, []>;
|
|
145
|
+
refreshAllSessions: jest.Mock<Promise<RefreshAllResponse>, []>;
|
|
146
|
+
refreshTokenViaCookie: jest.Mock<Promise<RefreshCookieResponse | null>, [{ authuser?: number }]>;
|
|
147
|
+
logoutSessionByAuthuser: jest.Mock<Promise<void>, [number]>;
|
|
148
|
+
logoutAllSessionsViaCookie: jest.Mock<Promise<void>, []>;
|
|
149
|
+
getCurrentUser: jest.Mock<Promise<User>, []>;
|
|
150
|
+
httpService: MockHttpService;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ACTIVE_AUTHUSER_KEY = 'oxy_active_authuser';
|
|
154
|
+
|
|
155
|
+
function makeMockServices(): MockServices {
|
|
156
|
+
return {
|
|
157
|
+
establishDeviceRefreshSlot: jest.fn(async () => null),
|
|
158
|
+
getAccessToken: jest.fn(() => null),
|
|
159
|
+
refreshAllSessions: jest.fn(async (): Promise<RefreshAllResponse> => ({ accounts: [] })),
|
|
160
|
+
refreshTokenViaCookie: jest.fn(),
|
|
161
|
+
logoutSessionByAuthuser: jest.fn(async () => undefined),
|
|
162
|
+
logoutAllSessionsViaCookie: jest.fn(async () => undefined),
|
|
163
|
+
getCurrentUser: jest.fn(async (): Promise<User> => ({ id: 'user-fedcm', publicKey: '0xabc', username: 'nate' } as User)),
|
|
164
|
+
httpService: { setTokens: jest.fn(), setAuthRefreshHandler: jest.fn() },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function makeManager(services: MockServices, storage: InMemoryStorage): AuthManager {
|
|
169
|
+
return new AuthManager(services as unknown as OxyServices, {
|
|
170
|
+
storage,
|
|
171
|
+
autoRefresh: false,
|
|
172
|
+
crossTabSync: false,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function fedcmSession(): SessionLoginResponse {
|
|
177
|
+
// No `authuser` claim in the FedCM-restored token — mirrors a cross-domain
|
|
178
|
+
// restore whose token is not slot-bound until /auth/session runs.
|
|
179
|
+
return {
|
|
180
|
+
accessToken: buildAccessToken({ sessionId: 'sess-fedcm', userId: 'user-fedcm', exp: 9999999999 }),
|
|
181
|
+
sessionId: 'sess-fedcm',
|
|
182
|
+
deviceId: 'dev-1',
|
|
183
|
+
expiresAt: '2099-01-01T00:00:00.000Z',
|
|
184
|
+
user: { id: 'user-fedcm', username: 'nate', name: { displayName: 'Nate' } },
|
|
185
|
+
} as SessionLoginResponse;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
describe('AuthManager.handleAuthSuccess — primary session joins the device set', () => {
|
|
189
|
+
it('establishes the device refresh slot and adopts the server-authoritative authuser', async () => {
|
|
190
|
+
const services = makeMockServices();
|
|
191
|
+
// /auth/session allocated slot 3 and rotated the token.
|
|
192
|
+
services.establishDeviceRefreshSlot.mockResolvedValueOnce(3);
|
|
193
|
+
const rotated = buildAccessToken({ sessionId: 'sess-fedcm', userId: 'user-fedcm', authuser: 3, exp: 9999999999 });
|
|
194
|
+
services.getAccessToken.mockReturnValue(rotated);
|
|
195
|
+
const storage = new InMemoryStorage();
|
|
196
|
+
const manager = makeManager(services, storage);
|
|
197
|
+
|
|
198
|
+
await manager.handleAuthSuccess(fedcmSession(), 'fedcm');
|
|
199
|
+
|
|
200
|
+
expect(services.establishDeviceRefreshSlot).toHaveBeenCalledTimes(1);
|
|
201
|
+
// The authoritative slot from /auth/session is recorded as the active account.
|
|
202
|
+
expect(manager.getActiveAuthuser()).toBe(3);
|
|
203
|
+
expect(storage.raw().get(ACTIVE_AUTHUSER_KEY)).toBe('3');
|
|
204
|
+
expect(manager.getActiveAccount()?.authuser).toBe(3);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('falls back to the JWT-decoded authuser (then 0) when /auth/session is a no-op (native / cross-apex)', async () => {
|
|
208
|
+
const services = makeMockServices();
|
|
209
|
+
services.establishDeviceRefreshSlot.mockResolvedValueOnce(null);
|
|
210
|
+
const storage = new InMemoryStorage();
|
|
211
|
+
const manager = makeManager(services, storage);
|
|
212
|
+
|
|
213
|
+
// FedCM session token carries no authuser claim → falls back to slot 0.
|
|
214
|
+
await manager.handleAuthSuccess(fedcmSession(), 'fedcm');
|
|
215
|
+
|
|
216
|
+
expect(manager.getActiveAuthuser()).toBe(0);
|
|
217
|
+
// getAccessToken() is only consulted for the rotated token when a slot was
|
|
218
|
+
// actually established — a null slot must not re-read it.
|
|
219
|
+
expect(services.getAccessToken).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -325,6 +325,7 @@ export type {
|
|
|
325
325
|
AssetUpdateVisibilityRequest,
|
|
326
326
|
AssetUpdateVisibilityResponse,
|
|
327
327
|
ServiceAssetMetadata,
|
|
328
|
+
ServiceAssetMetadataBySha,
|
|
328
329
|
AccountStorageCategoryUsage,
|
|
329
330
|
AccountStorageUsageResponse,
|
|
330
331
|
SecurityEventType,
|
|
@@ -539,12 +540,14 @@ export {
|
|
|
539
540
|
ssoNoSessionKey,
|
|
540
541
|
ssoAttemptedKey,
|
|
541
542
|
ssoPriorSessionKey,
|
|
543
|
+
ssoSignedOutKey,
|
|
542
544
|
ssoCallbackBootstrapKey,
|
|
543
545
|
ssoNavigate,
|
|
544
546
|
getSsoCallbackBootstrapScript,
|
|
545
547
|
buildSsoBounceUrl,
|
|
546
548
|
isCentralIdPOrigin,
|
|
547
549
|
guardActive,
|
|
550
|
+
silentRestoreSuppressed,
|
|
548
551
|
allowSsoBounce,
|
|
549
552
|
} from './utils/ssoBounce';
|
|
550
553
|
export type { SsoBounceGate } from './utils/ssoBounce';
|
|
@@ -35,8 +35,6 @@ import type { User } from '../models/interfaces';
|
|
|
35
35
|
import type { SessionLoginResponse } from '../models/session';
|
|
36
36
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
37
37
|
import { normalizeUserIdentity } from '../utils/userIdentity';
|
|
38
|
-
import { isWeb } from '../utils/platform';
|
|
39
|
-
import { logger } from '../utils/loggerUtils';
|
|
40
38
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
41
39
|
|
|
42
40
|
// ---------------------------------------------------------------------------
|
|
@@ -564,37 +562,16 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
564
562
|
|
|
565
563
|
// Register the switched session in the device's multi-account set by
|
|
566
564
|
// establishing its first-party refresh cookie. This MUST be a separate
|
|
567
|
-
// call to `POST /auth/session
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
'POST',
|
|
578
|
-
'/auth/session',
|
|
579
|
-
undefined,
|
|
580
|
-
{ cache: false },
|
|
581
|
-
);
|
|
582
|
-
if (typeof established?.authuser === 'number') {
|
|
583
|
-
authuser = established.authuser;
|
|
584
|
-
}
|
|
585
|
-
// /auth/session mints a fresh access token off the same session;
|
|
586
|
-
// re-plant it so the active token matches the rotated cookie.
|
|
587
|
-
if (established?.accessToken) {
|
|
588
|
-
this.setTokens(established.accessToken);
|
|
589
|
-
}
|
|
590
|
-
} catch (error) {
|
|
591
|
-
logger.warn(
|
|
592
|
-
'[OxyServices] Failed to establish device refresh cookie after account switch; the switch is active in-session but may not survive a reload',
|
|
593
|
-
{ component: 'OxyServices', method: 'switchToAccount' },
|
|
594
|
-
error,
|
|
595
|
-
);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
565
|
+
// call to `POST /auth/session` (the shared `establishDeviceRefreshSlot`
|
|
566
|
+
// primitive): the switch route is at `/accounts/*`, outside the
|
|
567
|
+
// `oxy_rt_<authuser>` cookie's `Path=/auth` scope, so it can never read
|
|
568
|
+
// the device's existing slots and would overwrite slot 0 (destroying the
|
|
569
|
+
// operator's own session). `/auth/session` runs where the cookies ARE
|
|
570
|
+
// visible, so the server allocates a NEW slot that coexists with the
|
|
571
|
+
// operator's and returns its `authuser`. Web-only; best-effort (the helper
|
|
572
|
+
// re-plants the rotated access token and returns `null` on native/failure).
|
|
573
|
+
const establishedAuthuser = await this.establishDeviceRefreshSlot();
|
|
574
|
+
const authuser = establishedAuthuser ?? res.authuser;
|
|
598
575
|
|
|
599
576
|
// Identity changed → drop the entire GET response cache so no entry
|
|
600
577
|
// personalised for the previous identity is reused. Cache keys are
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor, ServiceAssetMetadata } from '../models/interfaces';
|
|
1
|
+
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor, ServiceAssetMetadata, ServiceAssetMetadataBySha } from '../models/interfaces';
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
3
|
import { isReactNative } from '@oxyhq/protocol';
|
|
4
4
|
import { logger } from '../utils/loggerUtils';
|
|
@@ -12,6 +12,21 @@ import { extractErrorStatus } from '../utils/errorUtils';
|
|
|
12
12
|
*/
|
|
13
13
|
const SERVICE_ASSET_METADATA_CHUNK_SIZE = 100;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Maximum number of content hashes sent per `POST /assets/service/by-sha256`
|
|
17
|
+
* request. Matches the server-side cap (the route rejects empty or > 100 hash
|
|
18
|
+
* arrays with a 400); larger inputs are chunked and merged. Same ceiling as
|
|
19
|
+
* {@link SERVICE_ASSET_METADATA_CHUNK_SIZE} for the forward id lookup.
|
|
20
|
+
*/
|
|
21
|
+
const SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE = 100;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Lowercase hex SHA-256 digest matcher (exactly 64 hex chars). The reverse
|
|
25
|
+
* lookup drops non-conforming hashes client-side so a single malformed value
|
|
26
|
+
* never 400s an otherwise-valid chunk on the server.
|
|
27
|
+
*/
|
|
28
|
+
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
|
29
|
+
|
|
15
30
|
export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
16
31
|
return class extends Base {
|
|
17
32
|
constructor(...args: any[]) {
|
|
@@ -261,6 +276,82 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
261
276
|
return settled.flat();
|
|
262
277
|
}
|
|
263
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Reverse content-address lookup: resolve many content `sha256` digests to
|
|
281
|
+
* the servable Oxy asset holding each, in one round-trip per chunk via
|
|
282
|
+
* `POST /assets/service/by-sha256` (body `{ sha256s }`).
|
|
283
|
+
*
|
|
284
|
+
* This is the INVERSE of {@link getServiceAssetMetadataByIds}: given a
|
|
285
|
+
* record's `blob.sha256`, it returns the asset's `id`, `mime`, byte `size`,
|
|
286
|
+
* `status`, and — for active, public, CDN-reachable assets only — a public
|
|
287
|
+
* `url` (`cloud.oxy.so`). Built for server-to-server callers (e.g. Mention's
|
|
288
|
+
* MTN materializer / node-blob sync) that hold a content hash and need to
|
|
289
|
+
* map it back to a servable asset. Hashes are lowercased, validated against
|
|
290
|
+
* a 64-char hex pattern (malformed entries dropped client-side), and
|
|
291
|
+
* deduplicated before being split into chunks of
|
|
292
|
+
* {@link SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE} (the server-side cap). The
|
|
293
|
+
* server omits unknown/deleted hashes from each chunk's `data`, so the
|
|
294
|
+
* merged result may be shorter than the requested list and the caller is
|
|
295
|
+
* expected to map by `sha256`.
|
|
296
|
+
*
|
|
297
|
+
* **Service-token auth (required).** `/assets/service/by-sha256` is guarded
|
|
298
|
+
* by `serviceAuthMiddleware` + the `files:read` scope and is called via
|
|
299
|
+
* `makeServiceRequest` (`Authorization: Bearer <serviceToken>`, `cache:false`).
|
|
300
|
+
* The calling client MUST be service-configured (`configureServiceAuth`)
|
|
301
|
+
* before invoking; a plain user-session request is rejected by the route's
|
|
302
|
+
* service-auth guard.
|
|
303
|
+
*
|
|
304
|
+
* Resilience: chunks are independent. A failed chunk is logged and skipped —
|
|
305
|
+
* every entry that resolved is still returned. An empty input (or one whose
|
|
306
|
+
* every value is malformed) resolves immediately with `[]` and performs no
|
|
307
|
+
* network call.
|
|
308
|
+
*
|
|
309
|
+
* Not cached at the SDK layer: it's a POST keyed on a multi-hash body (low
|
|
310
|
+
* hit rate), mirroring the sibling service/POST methods which never cache.
|
|
311
|
+
*/
|
|
312
|
+
async getServiceAssetMetadataBySha256(sha256s: string[]): Promise<ServiceAssetMetadataBySha[]> {
|
|
313
|
+
const uniqueShas = Array.from(
|
|
314
|
+
new Set(
|
|
315
|
+
sha256s
|
|
316
|
+
.filter((sha): sha is string => typeof sha === 'string')
|
|
317
|
+
.map((sha) => sha.trim().toLowerCase())
|
|
318
|
+
.filter((sha) => SHA256_HEX_PATTERN.test(sha)),
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
if (uniqueShas.length === 0) {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const chunks: string[][] = [];
|
|
326
|
+
for (let i = 0; i < uniqueShas.length; i += SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE) {
|
|
327
|
+
chunks.push(uniqueShas.slice(i, i + SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Run chunks concurrently; a single chunk failure must not sink the rest.
|
|
331
|
+
const settled = await Promise.all(
|
|
332
|
+
chunks.map(async (chunk): Promise<ServiceAssetMetadataBySha[]> => {
|
|
333
|
+
try {
|
|
334
|
+
const entries = await this.makeServiceRequest<ServiceAssetMetadataBySha[]>(
|
|
335
|
+
'POST',
|
|
336
|
+
'/assets/service/by-sha256',
|
|
337
|
+
{ sha256s: chunk },
|
|
338
|
+
);
|
|
339
|
+
return Array.isArray(entries) ? entries : [];
|
|
340
|
+
} catch (error: unknown) {
|
|
341
|
+
logger.warn('getServiceAssetMetadataBySha256: chunk failed, continuing with remaining chunks', {
|
|
342
|
+
method: 'getServiceAssetMetadataBySha256',
|
|
343
|
+
chunkSize: chunk.length,
|
|
344
|
+
status: extractErrorStatus(error),
|
|
345
|
+
error: error instanceof Error ? error.message : String(error),
|
|
346
|
+
});
|
|
347
|
+
return [];
|
|
348
|
+
}
|
|
349
|
+
}),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
return settled.flat();
|
|
353
|
+
}
|
|
354
|
+
|
|
264
355
|
/**
|
|
265
356
|
* Upload raw file data
|
|
266
357
|
*/
|
|
@@ -88,6 +88,18 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
88
88
|
userId?: string,
|
|
89
89
|
) => Promise<R>;
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Raw service credentials stored by `configureServiceAuth()` on the auth
|
|
93
|
+
* mixin (earlier in the pipeline). Surfaced here via `declare` — for the
|
|
94
|
+
* same typing reason as `makeServiceRequest` above — so `getUsersByIds` can
|
|
95
|
+
* detect whether this instance is service-configured (a backend) and pick
|
|
96
|
+
* the bearer-service path, or fall back to the user-session path (a browser/
|
|
97
|
+
* RN client). Both are `null` until `configureServiceAuth(apiKey, apiSecret)`
|
|
98
|
+
* is called.
|
|
99
|
+
*/
|
|
100
|
+
declare _serviceApiKey: string | null;
|
|
101
|
+
declare _serviceApiSecret: string | null;
|
|
102
|
+
|
|
91
103
|
/**
|
|
92
104
|
* Get profile by username
|
|
93
105
|
*/
|
|
@@ -349,16 +361,28 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
349
361
|
* by `id`); each is run through `normalizeUserIdentity`, matching
|
|
350
362
|
* `getUserById`.
|
|
351
363
|
*
|
|
352
|
-
* **
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
364
|
+
* **Dual-mode auth.** `/users/by-ids` is `optionalUserOrServiceAuth` on
|
|
365
|
+
* oxy-api: it accepts a service token, a user session, or an anonymous
|
|
366
|
+
* caller, and returns the SAME public `{ data: PublicUserProfile[] }`
|
|
367
|
+
* payload (canonical `name.displayName` + `_count`) in every case — no
|
|
368
|
+
* viewer-specific fields. This method picks the path automatically:
|
|
369
|
+
* - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
|
|
370
|
+
* apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
|
|
371
|
+
* (attaches `Authorization: Bearer <serviceToken>`). This is the
|
|
372
|
+
* server-to-server feed/notification hydration path (e.g. Mention's
|
|
373
|
+
* `PostHydrationService`) and is unchanged.
|
|
374
|
+
* - **Plain client (browser / React Native with a user session):** when no
|
|
375
|
+
* service credentials are configured, the chunk is fetched via
|
|
376
|
+
* `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
|
|
377
|
+
* middleware skips bearer-authenticated writes, and `makeRequest` only
|
|
378
|
+
* fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
|
|
379
|
+
* so the user-bearer POST is sent without CSRF and succeeds. Previously
|
|
380
|
+
* this method always used the service path, so every client-side caller
|
|
381
|
+
* silently received `[]` because `getServiceToken()` had no credentials.
|
|
382
|
+
*
|
|
383
|
+
* Both paths run results through `normalizeUserIdentity` and unwrap the
|
|
384
|
+
* API's `{ data }` envelope identically (`makeServiceRequest` is literally
|
|
385
|
+
* `makeRequest` plus a bearer service header).
|
|
362
386
|
*
|
|
363
387
|
* Resilience: chunks are independent. A failed chunk is logged and skipped
|
|
364
388
|
* — the method returns every user that resolved successfully rather than
|
|
@@ -381,15 +405,23 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
381
405
|
chunks.push(uniqueIds.slice(i, i + USERS_BY_IDS_CHUNK_SIZE));
|
|
382
406
|
}
|
|
383
407
|
|
|
408
|
+
// A backend that called configureServiceAuth() uses the bearer-service
|
|
409
|
+
// path; any other caller (browser / RN with a user session) uses the
|
|
410
|
+
// user-bearer path. See the method doc for why the user path is CSRF-safe.
|
|
411
|
+
const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
|
|
412
|
+
|
|
384
413
|
// Run chunks concurrently; a single chunk failure must not sink the rest.
|
|
385
414
|
const settled = await Promise.all(
|
|
386
415
|
chunks.map(async (chunk): Promise<User[]> => {
|
|
387
416
|
try {
|
|
388
|
-
const users =
|
|
417
|
+
const users = useServiceAuth
|
|
418
|
+
? await this.makeServiceRequest<User[]>('POST', '/users/by-ids', { ids: chunk })
|
|
419
|
+
: await this.makeRequest<User[]>('POST', '/users/by-ids', { ids: chunk }, { cache: false });
|
|
389
420
|
return Array.isArray(users) ? users.map((user) => normalizeUserIdentity(user)) : [];
|
|
390
421
|
} catch (error: unknown) {
|
|
391
422
|
logger.warn('getUsersByIds: chunk failed, continuing with remaining chunks', {
|
|
392
423
|
method: 'getUsersByIds',
|
|
424
|
+
mode: useServiceAuth ? 'service' : 'user',
|
|
393
425
|
chunkSize: chunk.length,
|
|
394
426
|
status: extractErrorStatus(error),
|
|
395
427
|
error: error instanceof Error ? error.message : String(error),
|