@oxyhq/core 7.1.1 → 8.1.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/README.md +48 -24
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +6 -6
- package/dist/cjs/boot/coldBootV2.js +97 -2
- package/dist/cjs/boot/deviceBootReturn.js +15 -0
- package/dist/cjs/i18n/locales/en-US.json +44 -1
- package/dist/cjs/i18n/locales/es-ES.json +44 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +45 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +45 -2
- package/dist/cjs/index.js +19 -16
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +28 -0
- package/dist/cjs/server/index.js +1 -7
- package/dist/cjs/session/accountDialogController.js +1 -1
- package/dist/cjs/session/accountProjection.js +1 -1
- package/dist/cjs/session/authStateStore.js +6 -0
- package/dist/cjs/session/projectSessionState.js +1 -1
- package/dist/cjs/session/refresh.js +9 -0
- package/dist/cjs/session/sessionClientHost.js +1 -2
- package/dist/cjs/utils/accountUtils.js +1 -1
- package/dist/cjs/utils/oauthPkce.js +142 -0
- package/dist/cjs/utils/platform.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +6 -6
- package/dist/esm/boot/coldBootV2.js +97 -2
- package/dist/esm/boot/deviceBootReturn.js +15 -0
- package/dist/esm/i18n/locales/en-US.json +44 -1
- package/dist/esm/i18n/locales/es-ES.json +44 -1
- package/dist/esm/i18n/locales/locales/en-US.json +45 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +45 -2
- package/dist/esm/index.js +11 -13
- package/dist/esm/mixins/OxyServices.deviceBoot.js +29 -1
- package/dist/esm/server/index.js +0 -5
- package/dist/esm/session/accountDialogController.js +1 -1
- package/dist/esm/session/accountProjection.js +1 -1
- package/dist/esm/session/authStateStore.js +6 -0
- package/dist/esm/session/projectSessionState.js +1 -1
- package/dist/esm/session/refresh.js +9 -0
- package/dist/esm/session/sessionClientHost.js +1 -2
- package/dist/esm/utils/accountUtils.js +1 -1
- package/dist/esm/utils/oauthPkce.js +135 -0
- package/dist/esm/utils/platform.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +1 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/types/mixins/OxyServices.accounts.d.ts +13 -3
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +4 -0
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +17 -1
- package/dist/types/mixins/OxyServices.devices.d.ts +3 -2
- package/dist/types/models/interfaces.d.ts +4 -4
- package/dist/types/server/index.d.ts +0 -1
- package/dist/types/session/accountDialogController.d.ts +1 -1
- package/dist/types/session/accountProjection.d.ts +1 -1
- package/dist/types/session/authStateStore.d.ts +20 -0
- package/dist/types/session/projectSessionState.d.ts +1 -1
- package/dist/types/session/refresh.d.ts +4 -8
- package/dist/types/session/sessionClientHost.d.ts +1 -2
- package/dist/types/utils/accountUtils.d.ts +1 -1
- package/dist/types/utils/oauthPkce.d.ts +74 -0
- package/dist/types/utils/platform.d.ts +1 -1
- package/package.json +3 -3
- package/src/HttpService.ts +6 -6
- package/src/boot/__tests__/coldBootV2.test.ts +215 -1
- package/src/boot/__tests__/deviceBootReturn.test.ts +32 -0
- package/src/boot/coldBootV2.ts +117 -2
- package/src/boot/deviceBootReturn.ts +15 -0
- package/src/i18n/locales/en-US.json +45 -2
- package/src/i18n/locales/es-ES.json +45 -2
- package/src/index.ts +23 -16
- package/src/mixins/OxyServices.accounts.ts +12 -0
- package/src/mixins/OxyServices.connectedApps.ts +4 -0
- package/src/mixins/OxyServices.deviceBoot.ts +38 -0
- package/src/mixins/OxyServices.devices.ts +6 -5
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +44 -1
- package/src/mixins/__tests__/accounts.test.ts +1 -1
- package/src/models/interfaces.ts +7 -5
- package/src/server/index.ts +0 -6
- package/src/session/__tests__/authStateStore.test.ts +27 -0
- package/src/session/__tests__/refresh.test.ts +14 -0
- package/src/session/accountDialogController.ts +1 -1
- package/src/session/accountProjection.ts +1 -1
- package/src/session/authStateStore.ts +26 -0
- package/src/session/projectSessionState.ts +1 -1
- package/src/session/refresh.ts +13 -8
- package/src/session/sessionClientHost.ts +1 -2
- package/src/utils/__tests__/coldBoot.test.ts +55 -65
- package/src/utils/__tests__/oauthPkce.test.ts +154 -0
- package/src/utils/accountUtils.ts +1 -1
- package/src/utils/oauthPkce.ts +189 -0
- package/src/utils/platform.ts +1 -1
- package/dist/cjs/utils/ssoBounce.js +0 -24
- package/dist/esm/utils/ssoBounce.js +0 -21
- package/dist/types/utils/ssoBounce.d.ts +0 -21
- package/src/utils/ssoBounce.ts +0 -22
|
@@ -284,6 +284,10 @@ export interface Application {
|
|
|
284
284
|
name: string;
|
|
285
285
|
description?: string;
|
|
286
286
|
websiteUrl?: string;
|
|
287
|
+
/** Public privacy-policy URL, rendered as a legal link on the OAuth consent screen. */
|
|
288
|
+
privacyPolicyUrl?: string;
|
|
289
|
+
/** Public terms-of-service URL, rendered as a legal link on the OAuth consent screen. */
|
|
290
|
+
termsUrl?: string;
|
|
287
291
|
icon?: string;
|
|
288
292
|
type: ApplicationType;
|
|
289
293
|
status: ApplicationStatus;
|
|
@@ -342,6 +346,10 @@ export interface CreateApplicationInput {
|
|
|
342
346
|
name: string;
|
|
343
347
|
description?: string;
|
|
344
348
|
websiteUrl?: string;
|
|
349
|
+
/** Public privacy-policy URL (absolute `https://`). Shown on the OAuth consent screen. */
|
|
350
|
+
privacyPolicyUrl?: string;
|
|
351
|
+
/** Public terms-of-service URL (absolute `https://`). Shown on the OAuth consent screen. */
|
|
352
|
+
termsUrl?: string;
|
|
345
353
|
icon?: string;
|
|
346
354
|
redirectUris?: string[];
|
|
347
355
|
scopes?: string[];
|
|
@@ -357,6 +365,10 @@ export interface UpdateApplicationInput {
|
|
|
357
365
|
name?: string;
|
|
358
366
|
description?: string;
|
|
359
367
|
websiteUrl?: string;
|
|
368
|
+
/** Public privacy-policy URL (absolute `https://`, or `''` to clear). Shown on the OAuth consent screen. */
|
|
369
|
+
privacyPolicyUrl?: string;
|
|
370
|
+
/** Public terms-of-service URL (absolute `https://`, or `''` to clear). Shown on the OAuth consent screen. */
|
|
371
|
+
termsUrl?: string;
|
|
360
372
|
icon?: string;
|
|
361
373
|
redirectUris?: string[];
|
|
362
374
|
scopes?: string[];
|
|
@@ -41,6 +41,10 @@ export interface PublicApplication {
|
|
|
41
41
|
icon?: string;
|
|
42
42
|
/** Optional public website/homepage URL for the application. */
|
|
43
43
|
websiteUrl?: string;
|
|
44
|
+
/** Optional public privacy-policy URL, rendered as a legal link on the consent screen. */
|
|
45
|
+
privacyPolicyUrl?: string;
|
|
46
|
+
/** Optional public terms-of-service URL, rendered as a legal link on the consent screen. */
|
|
47
|
+
termsUrl?: string;
|
|
44
48
|
/** Application classification (set by Oxy platform staff). */
|
|
45
49
|
type: ApplicationType;
|
|
46
50
|
/** Whether the application is an officially endorsed Oxy application. */
|
|
@@ -19,9 +19,11 @@ import {
|
|
|
19
19
|
authTokenBundleSchema,
|
|
20
20
|
tokenRefreshResponseSchema,
|
|
21
21
|
deviceTokenIssueResponseSchema,
|
|
22
|
+
deviceTokenMintResponseSchema,
|
|
22
23
|
webSessionResultSchema,
|
|
23
24
|
safeParseContract,
|
|
24
25
|
type AuthTokenBundle,
|
|
26
|
+
type DeviceTokenMintResponse,
|
|
25
27
|
type TokenRefreshResponse,
|
|
26
28
|
type WebSessionResult,
|
|
27
29
|
} from '@oxyhq/contracts';
|
|
@@ -132,6 +134,42 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
132
134
|
}
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Zero-cookie mint (phase 2c). Present the first-party `deviceId` +
|
|
139
|
+
* `deviceSecret` to `POST /session/device/token` — NO bearer, NO cookies:
|
|
140
|
+
* possession of the secret IS the device-ownership proof. Returns a fresh
|
|
141
|
+
* short access token for the device's active account plus `nextDeviceSecret`
|
|
142
|
+
* (rotation-in-use) and the projected device-session `state`.
|
|
143
|
+
*
|
|
144
|
+
* `skipAuth` (like {@link refreshWithToken}): this call carries no bearer, so
|
|
145
|
+
* a 401 must surface DIRECTLY — never trigger `HttpService`'s 401→refresh→
|
|
146
|
+
* retry dance (which would pointlessly rotate the refresh family). The cold
|
|
147
|
+
* boot reads the 401 body (`invalid_device_secret` vs `no_active_session`) to
|
|
148
|
+
* decide whether to drop the secret and fall back or resolve signed-out.
|
|
149
|
+
*
|
|
150
|
+
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
151
|
+
*/
|
|
152
|
+
async mintFromDeviceSecret(
|
|
153
|
+
deviceId: string,
|
|
154
|
+
deviceSecret: string,
|
|
155
|
+
): Promise<DeviceTokenMintResponse> {
|
|
156
|
+
try {
|
|
157
|
+
const res = await this.makeRequest<unknown>(
|
|
158
|
+
'POST',
|
|
159
|
+
'/session/device/token',
|
|
160
|
+
{ deviceId, deviceSecret },
|
|
161
|
+
{ cache: false, skipAuth: true },
|
|
162
|
+
);
|
|
163
|
+
const parsed = safeParseContract(deviceTokenMintResponseSchema, res);
|
|
164
|
+
if (!parsed) {
|
|
165
|
+
throw new Error('session/device/token returned an unexpected response shape');
|
|
166
|
+
}
|
|
167
|
+
return parsed;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
throw this.handleError(error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
135
173
|
/**
|
|
136
174
|
* Build the top-level `GET /auth/device/bootstrap` URL for the cross-apex
|
|
137
175
|
* hop. The server validates `return_to` against the trusted-origin lane and
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Device Methods Mixin
|
|
3
3
|
*/
|
|
4
4
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
5
|
+
import type { DeviceLinkedSession, DeviceLinkedSessionLogoutResponse } from '../models/interfaces';
|
|
5
6
|
|
|
6
7
|
export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
7
8
|
return class extends Base {
|
|
@@ -54,11 +55,11 @@ export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
54
55
|
* @param sessionId - The session ID
|
|
55
56
|
* @returns Array of device sessions
|
|
56
57
|
*/
|
|
57
|
-
async getDeviceSessions(sessionId: string): Promise<
|
|
58
|
+
async getDeviceSessions(sessionId: string): Promise<DeviceLinkedSession[]> {
|
|
58
59
|
try {
|
|
59
60
|
// Use makeRequest for consistent error handling and optional caching
|
|
60
61
|
// Cache disabled by default to ensure fresh session data
|
|
61
|
-
return await this.makeRequest<
|
|
62
|
+
return await this.makeRequest<DeviceLinkedSession[]>('GET', `/session/device/sessions/${sessionId}`, undefined, {
|
|
62
63
|
cache: false, // Don't cache sessions - always get fresh data
|
|
63
64
|
deduplicate: true, // Deduplicate concurrent requests for same sessionId
|
|
64
65
|
});
|
|
@@ -74,12 +75,12 @@ export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
74
75
|
* @param excludeCurrent - Whether to exclude the current session
|
|
75
76
|
* @returns Logout result
|
|
76
77
|
*/
|
|
77
|
-
async logoutAllDeviceSessions(sessionId: string, deviceId?: string, excludeCurrent?: boolean): Promise<
|
|
78
|
+
async logoutAllDeviceSessions(sessionId: string, deviceId?: string, excludeCurrent?: boolean): Promise<DeviceLinkedSessionLogoutResponse> {
|
|
78
79
|
try {
|
|
79
|
-
const urlParams:
|
|
80
|
+
const urlParams: Record<string, string> = {};
|
|
80
81
|
if (deviceId) urlParams.deviceId = deviceId;
|
|
81
82
|
if (excludeCurrent) urlParams.excludeCurrent = 'true';
|
|
82
|
-
return await this.makeRequest('POST', `/session/device/logout-all/${sessionId}`, urlParams, { cache: false });
|
|
83
|
+
return await this.makeRequest<DeviceLinkedSessionLogoutResponse>('POST', `/session/device/logout-all/${sessionId}`, urlParams, { cache: false });
|
|
83
84
|
} catch (error) {
|
|
84
85
|
throw this.handleError(error);
|
|
85
86
|
}
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
* and asserts each method's route/shape, contract validation, and the
|
|
4
4
|
* `skipAuth` flag on the refresh call.
|
|
5
5
|
*/
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
AuthTokenBundle,
|
|
8
|
+
DeviceTokenMintResponse,
|
|
9
|
+
TokenRefreshResponse,
|
|
10
|
+
WebSessionResult,
|
|
11
|
+
} from '@oxyhq/contracts';
|
|
7
12
|
import { OxyServices } from '../../OxyServices';
|
|
8
13
|
|
|
9
14
|
const BUNDLE: AuthTokenBundle = {
|
|
@@ -96,6 +101,44 @@ describe('OxyServices.deviceBoot', () => {
|
|
|
96
101
|
});
|
|
97
102
|
});
|
|
98
103
|
|
|
104
|
+
describe('mintFromDeviceSecret', () => {
|
|
105
|
+
const MINT: DeviceTokenMintResponse = {
|
|
106
|
+
accessToken: 'access-minted',
|
|
107
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
108
|
+
nextDeviceSecret: 'ds-next-secret',
|
|
109
|
+
state: {
|
|
110
|
+
deviceId: 'dev-1',
|
|
111
|
+
accounts: [{ accountId: 'user-1', sessionId: 'sess-1', authuser: 0 }],
|
|
112
|
+
activeAccountId: 'user-1',
|
|
113
|
+
revision: 3,
|
|
114
|
+
updatedAt: 1_700_000_000_000,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
it('POSTs deviceId + deviceSecret with skipAuth and returns the validated mint', async () => {
|
|
119
|
+
makeRequest.mockResolvedValueOnce(MINT);
|
|
120
|
+
const result = await oxy.mintFromDeviceSecret('dev-1', 'ds-current-secret');
|
|
121
|
+
expect(result).toEqual(MINT);
|
|
122
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
123
|
+
'POST',
|
|
124
|
+
'/session/device/token',
|
|
125
|
+
{ deviceId: 'dev-1', deviceSecret: 'ds-current-secret' },
|
|
126
|
+
{ cache: false, skipAuth: true },
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('throws on an unexpected response shape', async () => {
|
|
131
|
+
makeRequest.mockResolvedValueOnce({ accessToken: 'a', expiresAt: 'b' });
|
|
132
|
+
await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('propagates a rejected request (e.g. 401 invalid_device_secret)', async () => {
|
|
136
|
+
const err = Object.assign(new Error('invalid_device_secret'), { status: 401 });
|
|
137
|
+
makeRequest.mockRejectedValueOnce(err);
|
|
138
|
+
await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow('invalid_device_secret');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
99
142
|
describe('buildBootstrapUrl', () => {
|
|
100
143
|
it('builds the bootstrap URL with encoded params', () => {
|
|
101
144
|
const url = oxy.buildBootstrapUrl('https://accounts.oxy.so/home', 'st-1');
|
|
@@ -83,7 +83,7 @@ const appFixture: Application = {
|
|
|
83
83
|
isOfficial: true,
|
|
84
84
|
isInternal: false,
|
|
85
85
|
capabilities: [],
|
|
86
|
-
redirectUris: ['https://mention.earth/
|
|
86
|
+
redirectUris: ['https://mention.earth/oauth/callback'],
|
|
87
87
|
scopes: ['profile'],
|
|
88
88
|
createdByUserId: 'u1',
|
|
89
89
|
ownerAccountId: 'acc1',
|
package/src/models/interfaces.ts
CHANGED
|
@@ -645,8 +645,10 @@ export interface AssetUploadProgress {
|
|
|
645
645
|
error?: string;
|
|
646
646
|
}
|
|
647
647
|
|
|
648
|
-
// Device
|
|
649
|
-
|
|
648
|
+
// Device-linked session interfaces — the sessions that share one physical
|
|
649
|
+
// device (GET /session/device/sessions/:sessionId). Distinct from the
|
|
650
|
+
// server-authority `DeviceSession` Mongoose model / `DeviceSessionState`.
|
|
651
|
+
export interface DeviceLinkedSession {
|
|
650
652
|
sessionId: string;
|
|
651
653
|
deviceId: string;
|
|
652
654
|
deviceName: string;
|
|
@@ -658,12 +660,12 @@ export interface DeviceSession {
|
|
|
658
660
|
createdAt?: string;
|
|
659
661
|
}
|
|
660
662
|
|
|
661
|
-
export interface
|
|
663
|
+
export interface DeviceLinkedSessionsResponse {
|
|
662
664
|
deviceId: string;
|
|
663
|
-
sessions:
|
|
665
|
+
sessions: DeviceLinkedSession[];
|
|
664
666
|
}
|
|
665
667
|
|
|
666
|
-
export interface
|
|
668
|
+
export interface DeviceLinkedSessionLogoutResponse {
|
|
667
669
|
message: string;
|
|
668
670
|
deviceId: string;
|
|
669
671
|
sessionsTerminated: number;
|
package/src/server/index.ts
CHANGED
|
@@ -69,9 +69,3 @@ export { verifySecret } from './verifySecret';
|
|
|
69
69
|
// Pure host handling (no browser deps), so it is safe on the server subpath and
|
|
70
70
|
// lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
|
|
71
71
|
export { registrableApex } from '../utils/registrableApex';
|
|
72
|
-
|
|
73
|
-
// The single RP callback path the IdP redirects back to. A pure wire-contract
|
|
74
|
-
// constant (no browser deps at module top level), re-used server-side so the
|
|
75
|
-
// `/sso/establish-token` `return_to` cannot drift from what `/sso/establish`
|
|
76
|
-
// validates.
|
|
77
|
-
export { SSO_CALLBACK_PATH } from '../utils/ssoBounce';
|
|
@@ -65,6 +65,33 @@ describe('createWebAuthStateStore', () => {
|
|
|
65
65
|
expect(await store.load()).toEqual(SAMPLE);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it('round-trips the optional phase-2c deviceId + deviceSecret', async () => {
|
|
69
|
+
installLocalStorage(makeFakeStorage());
|
|
70
|
+
const store = createWebAuthStateStore();
|
|
71
|
+
const withCreds: PersistedAuthState = { ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' };
|
|
72
|
+
|
|
73
|
+
await store.save(withCreds);
|
|
74
|
+
const loaded = await store.load();
|
|
75
|
+
expect(loaded?.deviceId).toBe('dev-abc');
|
|
76
|
+
expect(loaded?.deviceSecret).toBe('ds-secret-xyz');
|
|
77
|
+
expect(loaded).toEqual(withCreds);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('deserializes a legacy blob with no device credentials (additive — fields absent)', async () => {
|
|
81
|
+
const storage = makeFakeStorage();
|
|
82
|
+
installLocalStorage(storage);
|
|
83
|
+
const store = createWebAuthStateStore();
|
|
84
|
+
|
|
85
|
+
storage.setItem(
|
|
86
|
+
AUTH_STATE_STORAGE_KEY,
|
|
87
|
+
JSON.stringify({ sessionId: 's-1', refreshToken: 'r-abcdefghijklmnop', userId: 'u-1' }),
|
|
88
|
+
);
|
|
89
|
+
const loaded = await store.load();
|
|
90
|
+
expect(loaded).not.toBeNull();
|
|
91
|
+
expect(loaded && 'deviceId' in loaded).toBe(false);
|
|
92
|
+
expect(loaded && 'deviceSecret' in loaded).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
68
95
|
it('clear() wipes the session but the deviceToken survives', async () => {
|
|
69
96
|
installLocalStorage(makeFakeStorage());
|
|
70
97
|
const store = createWebAuthStateStore();
|
|
@@ -53,6 +53,20 @@ describe('refreshPersistedSession — arm 1 (refresh-token rotation)', () => {
|
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
it('carries the persisted deviceId + deviceSecret (phase 2c) forward across a rotation', async () => {
|
|
57
|
+
const store = createMemoryAuthStateStore();
|
|
58
|
+
await store.save({ ...STORED, deviceId: 'dev-mint', deviceSecret: 'ds-secret-orig' });
|
|
59
|
+
const { oxy } = makeOxy();
|
|
60
|
+
|
|
61
|
+
await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
62
|
+
|
|
63
|
+
const persisted = await store.load();
|
|
64
|
+
expect(persisted?.deviceId).toBe('dev-mint');
|
|
65
|
+
expect(persisted?.deviceSecret).toBe('ds-secret-orig');
|
|
66
|
+
// The refresh family head still rotated.
|
|
67
|
+
expect(persisted?.refreshToken).toBe('refresh-new-abcdefghij');
|
|
68
|
+
});
|
|
69
|
+
|
|
56
70
|
it('clears the store on a family-revoked (401) error', async () => {
|
|
57
71
|
const store = createMemoryAuthStateStore();
|
|
58
72
|
await store.save(STORED);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A framework-agnostic state machine + subscribe/getSnapshot store (the same
|
|
5
5
|
* pattern {@link SessionClient} uses — no React, no RN) that both
|
|
6
|
-
*
|
|
6
|
+
* every `OxyProvider` platform variant (Expo/RN and RN-Web)
|
|
7
7
|
* bind to via `useSyncExternalStore`, so the account chooser is ONE
|
|
8
8
|
* implementation across the ecosystem instead of the five drifting copies it
|
|
9
9
|
* replaces.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* merging the device's server-authoritative session set (`DeviceSessionState`
|
|
6
6
|
* from {@link SessionClient}) with the caller's account graph (`AccountNode[]`
|
|
7
7
|
* from `oxyServices.listAccounts()`), deduped by `accountId`. This lives in
|
|
8
|
-
* `@oxyhq/core` so `@oxyhq/services`
|
|
8
|
+
* `@oxyhq/core` so every `@oxyhq/services` platform variant — and
|
|
9
9
|
* `auth.oxy.so` — all render the SAME list from the SAME logic and cannot
|
|
10
10
|
* diverge.
|
|
11
11
|
*
|
|
@@ -39,6 +39,26 @@ export interface PersistedAuthState {
|
|
|
39
39
|
refreshToken: string;
|
|
40
40
|
userId: string;
|
|
41
41
|
deviceToken?: string;
|
|
42
|
+
/**
|
|
43
|
+
* The stable device identifier this session is bound to (phase 2c —
|
|
44
|
+
* zero-cookie transport). Persisted alongside {@link deviceSecret} because the
|
|
45
|
+
* `POST /session/device/token` mint presents BOTH — the secret is the proof,
|
|
46
|
+
* the deviceId selects the device doc. Sourced from the lanes that carry it
|
|
47
|
+
* (password login / 2FA / QR claim / challenge verify); the cookie-bootstrap
|
|
48
|
+
* lanes (`AuthTokenBundle`) omit it and preserve any prior value. Additive: a
|
|
49
|
+
* blob without it simply never takes the mint lane and falls back to the
|
|
50
|
+
* refresh family.
|
|
51
|
+
*/
|
|
52
|
+
deviceId?: string;
|
|
53
|
+
/**
|
|
54
|
+
* The rotating device secret (phase 2c — zero-cookie transport). Possession of
|
|
55
|
+
* it mints a short access token for the device's active account via
|
|
56
|
+
* `POST /session/device/token`, replacing the cookie lane. Rotated in-use: the
|
|
57
|
+
* mint returns `nextDeviceSecret`, which the cold boot persists BEFORE planting
|
|
58
|
+
* the minted access token (multi-tab anti-loss). Additive and optional — same
|
|
59
|
+
* XSS risk profile as the already-persisted `refreshToken`.
|
|
60
|
+
*/
|
|
61
|
+
deviceSecret?: string;
|
|
42
62
|
/** Optional warm-boot access token (short-lived; see interface docs). */
|
|
43
63
|
accessToken?: string;
|
|
44
64
|
/** Optional warm-boot access-token expiry, ISO-8601. */
|
|
@@ -130,6 +150,12 @@ function deserialize(raw: string | null): PersistedAuthState | null {
|
|
|
130
150
|
if (typeof candidate.deviceToken === 'string' && candidate.deviceToken.length > 0) {
|
|
131
151
|
state.deviceToken = candidate.deviceToken;
|
|
132
152
|
}
|
|
153
|
+
if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
|
|
154
|
+
state.deviceId = candidate.deviceId;
|
|
155
|
+
}
|
|
156
|
+
if (typeof candidate.deviceSecret === 'string' && candidate.deviceSecret.length > 0) {
|
|
157
|
+
state.deviceSecret = candidate.deviceSecret;
|
|
158
|
+
}
|
|
133
159
|
if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
|
|
134
160
|
state.accessToken = candidate.accessToken;
|
|
135
161
|
}
|
|
@@ -5,7 +5,7 @@ import type { User } from '../models/interfaces';
|
|
|
5
5
|
/**
|
|
6
6
|
* Pure projection helpers: `DeviceSessionState` (the device-scoped
|
|
7
7
|
* multi-account session-sync state produced by `SessionClient`) -> the
|
|
8
|
-
* shapes
|
|
8
|
+
* shapes `@oxyhq/services` consumers render today
|
|
9
9
|
* (`ClientSession[]`, an active session id, an active `User`).
|
|
10
10
|
*
|
|
11
11
|
* No I/O. The caller fetches profiles via
|
package/src/session/refresh.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unified token refresh — THE single refresh implementation for web + native.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `@oxyhq/services`'s `inSessionTokenRefresh.ts` (native shared-key). This
|
|
7
|
-
* module replaces both with ONE persisted-refresh-token rotation shared by
|
|
8
|
-
* every consumer:
|
|
4
|
+
* ONE persisted-refresh-token rotation shared by every consumer (it replaced
|
|
5
|
+
* the pre-device-first per-platform duplicates):
|
|
9
6
|
*
|
|
10
7
|
* - `refreshPersistedSession` — arm 1 rotates the stored refresh-token family
|
|
11
8
|
* (`POST /auth/refresh-token`), planting + persisting the rotated pair; arm 2
|
|
@@ -16,9 +13,8 @@
|
|
|
16
13
|
* - `createAuthRefreshHandler` / `installAuthRefreshHandler` wire arm 1+2 into
|
|
17
14
|
* `HttpService.setAuthRefreshHandler`, keeping that layer's single-flight
|
|
18
15
|
* dedup + cooldown (this module does NOT reimplement them).
|
|
19
|
-
* - `startTokenRefreshScheduler` — a proactive scheduler
|
|
20
|
-
*
|
|
21
|
-
* decoupled from any React / auth-sdk type: refreshes ~60s before `exp`,
|
|
16
|
+
* - `startTokenRefreshScheduler` — a proactive scheduler decoupled from any
|
|
17
|
+
* React type: refreshes ~60s before `exp`,
|
|
22
18
|
* re-arms on token change + web tab-focus, `.unref?.()`s its timer in Node.
|
|
23
19
|
*
|
|
24
20
|
* Framework-free; no module-level mutable state.
|
|
@@ -138,6 +134,15 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
138
134
|
if (persisted.deviceToken) {
|
|
139
135
|
next.deviceToken = persisted.deviceToken;
|
|
140
136
|
}
|
|
137
|
+
// The refresh response carries no device credentials — carry the persisted
|
|
138
|
+
// deviceId/deviceSecret (phase 2c) forward so a rotation never drops the
|
|
139
|
+
// zero-cookie mint lane (mirrors the deviceToken preservation above).
|
|
140
|
+
if (persisted.deviceId) {
|
|
141
|
+
next.deviceId = persisted.deviceId;
|
|
142
|
+
}
|
|
143
|
+
if (persisted.deviceSecret) {
|
|
144
|
+
next.deviceSecret = persisted.deviceSecret;
|
|
145
|
+
}
|
|
141
146
|
await store.save(next);
|
|
142
147
|
return rotated.accessToken;
|
|
143
148
|
} catch (error) {
|
|
@@ -7,8 +7,7 @@ import type { SessionClientHost } from './SessionClient';
|
|
|
7
7
|
* `SessionClient` is host-agnostic: it only needs a REST + token surface.
|
|
8
8
|
* `OxyServices` already exposes all of that except `getCurrentAccountId`,
|
|
9
9
|
* which has no direct equivalent — the adapter holds a mutable ref set by
|
|
10
|
-
* the caller (`OxyContext` in `@oxyhq/services
|
|
11
|
-
* `@oxyhq/auth`) via `setCurrentAccountId`.
|
|
10
|
+
* the caller (`OxyContext` in `@oxyhq/services`) via `setCurrentAccountId`.
|
|
12
11
|
*
|
|
13
12
|
* Shared here (rather than duplicated per consumer) because it is entirely
|
|
14
13
|
* platform-agnostic: every method it calls exists identically on
|