@oxyhq/core 8.1.0 → 9.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 +1 -3
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/coldBootV2.js +22 -332
- package/dist/cjs/crypto/keyManager.js +0 -95
- package/dist/cjs/index.js +6 -19
- package/dist/cjs/mixins/OxyServices.auth.js +4 -7
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
- package/dist/cjs/mixins/index.js +2 -3
- package/dist/cjs/session/SessionClient.js +29 -100
- package/dist/cjs/session/accountDialogController.js +0 -13
- package/dist/cjs/session/authStateStore.js +9 -89
- package/dist/cjs/session/createSessionClient.js +2 -9
- package/dist/cjs/session/refresh.js +46 -71
- package/dist/cjs/utils/registrableApex.js +2 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +23 -330
- package/dist/esm/crypto/keyManager.js +0 -95
- package/dist/esm/index.js +7 -11
- package/dist/esm/mixins/OxyServices.auth.js +4 -7
- package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
- package/dist/esm/mixins/index.js +2 -3
- package/dist/esm/session/SessionClient.js +29 -100
- package/dist/esm/session/accountDialogController.js +0 -13
- package/dist/esm/session/authStateStore.js +8 -88
- package/dist/esm/session/createSessionClient.js +2 -9
- package/dist/esm/session/refresh.js +46 -71
- package/dist/esm/utils/registrableApex.js +2 -6
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -3
- package/dist/types/boot/coldBootV2.d.ts +28 -53
- package/dist/types/crypto/keyManager.d.ts +0 -21
- package/dist/types/index.d.ts +3 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
- package/dist/types/models/interfaces.d.ts +15 -0
- package/dist/types/session/SessionClient.d.ts +6 -40
- package/dist/types/session/accountDialogController.d.ts +1 -3
- package/dist/types/session/authStateStore.d.ts +32 -57
- package/dist/types/session/createSessionClient.d.ts +2 -9
- package/dist/types/session/refresh.d.ts +32 -28
- package/dist/types/utils/registrableApex.d.ts +2 -6
- package/package.json +3 -3
- package/src/HttpService.ts +3 -3
- package/src/boot/__tests__/coldBootV2.test.ts +140 -353
- package/src/boot/coldBootV2.ts +35 -391
- package/src/crypto/keyManager.ts +0 -101
- package/src/index.ts +7 -30
- package/src/mixins/OxyServices.auth.ts +5 -9
- package/src/mixins/OxyServices.deviceBoot.ts +19 -142
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
- package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
- package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
- package/src/mixins/index.ts +2 -3
- package/src/models/interfaces.ts +15 -0
- package/src/session/SessionClient.ts +31 -122
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
- package/src/session/__tests__/authStateStore.test.ts +29 -42
- package/src/session/__tests__/refresh.test.ts +66 -52
- package/src/session/accountDialogController.ts +4 -18
- package/src/session/authStateStore.ts +32 -126
- package/src/session/createSessionClient.ts +2 -9
- package/src/session/refresh.ts +60 -92
- package/src/utils/registrableApex.ts +2 -6
- package/dist/cjs/boot/deviceBootReturn.js +0 -167
- package/dist/esm/boot/deviceBootReturn.js +0 -161
- package/dist/types/boot/deviceBootReturn.d.ts +0 -83
- package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
- package/src/boot/deviceBootReturn.ts +0 -210
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
- package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
import type { OxyServices } from '../../OxyServices';
|
|
2
|
-
import type {
|
|
2
|
+
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
3
3
|
import type { SessionLoginResponse } from '../../models/session';
|
|
4
4
|
import { refreshPersistedSession, startTokenRefreshScheduler } from '../refresh';
|
|
5
5
|
import { createMemoryAuthStateStore, type PersistedAuthState } from '../authStateStore';
|
|
6
6
|
|
|
7
|
+
/** The persisted zero-cookie mint credential the refresh reads. */
|
|
7
8
|
const STORED: PersistedAuthState = {
|
|
8
9
|
sessionId: 'sess-old',
|
|
9
|
-
refreshToken: 'refresh-old-abcdefghij',
|
|
10
10
|
userId: 'user-1',
|
|
11
|
-
|
|
11
|
+
deviceId: 'dev-mint',
|
|
12
|
+
deviceSecret: 'ds-secret-orig',
|
|
12
13
|
};
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
/** A successful `POST /session/device/token` mint (rotates the secret + active account). */
|
|
16
|
+
const MINT: DeviceTokenMintResponse = {
|
|
15
17
|
accessToken: 'access-new',
|
|
16
|
-
refreshToken: 'refresh-new-abcdefghij',
|
|
17
18
|
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
18
|
-
|
|
19
|
+
nextDeviceSecret: 'ds-next-secret',
|
|
20
|
+
state: {
|
|
21
|
+
deviceId: 'dev-mint',
|
|
22
|
+
accounts: [{ accountId: 'user-1', sessionId: 'sess-new', authuser: 0 }],
|
|
23
|
+
activeAccountId: 'user-1',
|
|
24
|
+
revision: 4,
|
|
25
|
+
updatedAt: 1_700_000_000_000,
|
|
26
|
+
},
|
|
19
27
|
};
|
|
20
28
|
|
|
21
29
|
interface RefreshMockOverrides {
|
|
22
|
-
|
|
30
|
+
mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
|
|
23
31
|
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
24
32
|
}
|
|
25
33
|
|
|
@@ -27,14 +35,14 @@ function makeOxy(overrides: RefreshMockOverrides = {}): { oxy: OxyServices; setT
|
|
|
27
35
|
const setTokens = jest.fn();
|
|
28
36
|
const oxy = {
|
|
29
37
|
setTokens,
|
|
30
|
-
|
|
38
|
+
mintFromDeviceSecret: overrides.mintFromDeviceSecret ?? (async () => MINT),
|
|
31
39
|
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
32
40
|
} as unknown as OxyServices;
|
|
33
41
|
return { oxy, setTokens };
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
describe('refreshPersistedSession — arm 1 (
|
|
37
|
-
it('
|
|
44
|
+
describe('refreshPersistedSession — arm 1 (device-secret mint)', () => {
|
|
45
|
+
it('mints, plants, persists the rotated secret + active account, and returns the token', async () => {
|
|
38
46
|
const store = createMemoryAuthStateStore();
|
|
39
47
|
await store.save(STORED);
|
|
40
48
|
const { oxy, setTokens } = makeOxy();
|
|
@@ -45,67 +53,82 @@ describe('refreshPersistedSession — arm 1 (refresh-token rotation)', () => {
|
|
|
45
53
|
expect(setTokens).toHaveBeenCalledWith('access-new');
|
|
46
54
|
expect(await store.load()).toEqual({
|
|
47
55
|
sessionId: 'sess-new',
|
|
48
|
-
refreshToken: 'refresh-new-abcdefghij',
|
|
49
56
|
userId: 'user-1',
|
|
50
|
-
|
|
57
|
+
deviceId: 'dev-mint',
|
|
58
|
+
deviceSecret: 'ds-next-secret',
|
|
51
59
|
accessToken: 'access-new',
|
|
52
60
|
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
53
61
|
});
|
|
54
62
|
});
|
|
55
63
|
|
|
56
|
-
it('
|
|
64
|
+
it('presents the persisted deviceId + deviceSecret to the mint', async () => {
|
|
57
65
|
const store = createMemoryAuthStateStore();
|
|
58
|
-
await store.save(
|
|
59
|
-
const
|
|
66
|
+
await store.save(STORED);
|
|
67
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
68
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
60
69
|
|
|
61
70
|
await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
62
71
|
|
|
63
|
-
|
|
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');
|
|
72
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
|
|
68
73
|
});
|
|
69
74
|
|
|
70
|
-
it('
|
|
75
|
+
it('drops only the secret (keeps deviceId) on a 401 and falls to shared-key when native', async () => {
|
|
71
76
|
const store = createMemoryAuthStateStore();
|
|
72
77
|
await store.save(STORED);
|
|
78
|
+
const SHARED: SessionLoginResponse = {
|
|
79
|
+
sessionId: 'sess-shared',
|
|
80
|
+
deviceId: 'dev-1',
|
|
81
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
82
|
+
user: { id: 'user-1', username: 'u', name: {}, avatar: undefined },
|
|
83
|
+
accessToken: 'access-shared',
|
|
84
|
+
};
|
|
85
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED);
|
|
73
86
|
const { oxy } = makeOxy({
|
|
74
|
-
|
|
75
|
-
throw Object.assign(new Error('
|
|
87
|
+
mintFromDeviceSecret: async () => {
|
|
88
|
+
throw Object.assign(new Error('invalid_device_secret'), { status: 401 });
|
|
76
89
|
},
|
|
90
|
+
signInWithSharedIdentity,
|
|
77
91
|
});
|
|
78
92
|
|
|
79
|
-
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback:
|
|
93
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
80
94
|
|
|
81
|
-
expect(token).
|
|
82
|
-
expect(
|
|
95
|
+
expect(token).toBe('access-shared');
|
|
96
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
97
|
+
const persisted = await store.load();
|
|
98
|
+
expect(persisted?.deviceSecret).toBeUndefined();
|
|
99
|
+
expect(persisted?.deviceId).toBe('dev-mint');
|
|
83
100
|
});
|
|
84
101
|
|
|
85
|
-
it('clears the store on
|
|
102
|
+
it('clears the store on a 401 when there is no shared-key fallback (web)', async () => {
|
|
86
103
|
const store = createMemoryAuthStateStore();
|
|
87
104
|
await store.save(STORED);
|
|
88
105
|
const { oxy } = makeOxy({
|
|
89
|
-
|
|
90
|
-
throw Object.assign(new Error('
|
|
106
|
+
mintFromDeviceSecret: async () => {
|
|
107
|
+
throw Object.assign(new Error('no_active_session'), { status: 401 });
|
|
91
108
|
},
|
|
92
109
|
});
|
|
93
110
|
|
|
94
|
-
|
|
111
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
112
|
+
|
|
113
|
+
expect(token).toBeNull();
|
|
95
114
|
expect(await store.load()).toBeNull();
|
|
96
115
|
});
|
|
97
116
|
|
|
98
|
-
it('KEEPS the store on a transient (500 / network) error', async () => {
|
|
117
|
+
it('KEEPS the store and returns null on a transient (500 / network) error', async () => {
|
|
99
118
|
const store = createMemoryAuthStateStore();
|
|
100
119
|
await store.save(STORED);
|
|
120
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
101
121
|
const { oxy } = makeOxy({
|
|
102
|
-
|
|
122
|
+
mintFromDeviceSecret: async () => {
|
|
103
123
|
throw Object.assign(new Error('server'), { status: 500 });
|
|
104
124
|
},
|
|
125
|
+
signInWithSharedIdentity,
|
|
105
126
|
});
|
|
106
127
|
|
|
107
|
-
|
|
128
|
+
// A transient failure must NOT drop the credential nor fall through to shared-key.
|
|
129
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).toBeNull();
|
|
108
130
|
expect(await store.load()).toEqual(STORED);
|
|
131
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
109
132
|
});
|
|
110
133
|
});
|
|
111
134
|
|
|
@@ -118,8 +141,8 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
|
|
|
118
141
|
accessToken: 'access-shared',
|
|
119
142
|
};
|
|
120
143
|
|
|
121
|
-
it('re-mints via shared identity when there is no
|
|
122
|
-
const store = createMemoryAuthStateStore(); // no stored
|
|
144
|
+
it('re-mints via shared identity when there is no persisted secret', async () => {
|
|
145
|
+
const store = createMemoryAuthStateStore(); // no stored device secret
|
|
123
146
|
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
124
147
|
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
125
148
|
|
|
@@ -129,31 +152,22 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
|
|
|
129
152
|
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
130
153
|
});
|
|
131
154
|
|
|
132
|
-
it('
|
|
155
|
+
it('does NOT try shared-key when the fallback is disabled (web)', async () => {
|
|
133
156
|
const store = createMemoryAuthStateStore();
|
|
134
|
-
await store.save(STORED);
|
|
135
157
|
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
136
|
-
const { oxy } = makeOxy({
|
|
137
|
-
refreshWithToken: async () => {
|
|
138
|
-
throw Object.assign(new Error('revoked'), { status: 403 });
|
|
139
|
-
},
|
|
140
|
-
signInWithSharedIdentity,
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
158
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
144
159
|
|
|
145
|
-
expect(
|
|
146
|
-
expect(
|
|
147
|
-
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
160
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
|
|
161
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
148
162
|
});
|
|
149
163
|
|
|
150
|
-
it('
|
|
164
|
+
it('returns null when the shared-key re-mint yields no session', async () => {
|
|
151
165
|
const store = createMemoryAuthStateStore();
|
|
152
|
-
const signInWithSharedIdentity = jest.fn(async () =>
|
|
166
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
153
167
|
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
154
168
|
|
|
155
|
-
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback:
|
|
156
|
-
expect(signInWithSharedIdentity).
|
|
169
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).toBeNull();
|
|
170
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
157
171
|
});
|
|
158
172
|
});
|
|
159
173
|
|
|
@@ -106,7 +106,7 @@ export interface AccountDialogControllerOptions {
|
|
|
106
106
|
* falls back to `SessionClient.registerAndActivate` (registration + activation
|
|
107
107
|
* only — no provider-side durable persist/hydration).
|
|
108
108
|
*/
|
|
109
|
-
commitSession?: (session: SessionLoginResponse
|
|
109
|
+
commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
110
110
|
/** Notified after a completed sign-in (bearer planted + session committed). */
|
|
111
111
|
onSignedIn?: (user: MinimalUserData) => void;
|
|
112
112
|
/** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
|
|
@@ -142,7 +142,7 @@ export class AccountDialogController {
|
|
|
142
142
|
private readonly sessionClient: SessionClient;
|
|
143
143
|
private readonly clientId: string | null;
|
|
144
144
|
private readonly locale?: string;
|
|
145
|
-
private readonly commitSession?: (session: SessionLoginResponse
|
|
145
|
+
private readonly commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
146
146
|
private readonly onSignedIn?: (user: MinimalUserData) => void;
|
|
147
147
|
private readonly idpApex: string;
|
|
148
148
|
private readonly pollIntervalMs: number;
|
|
@@ -454,7 +454,6 @@ export class AccountDialogController {
|
|
|
454
454
|
expiresAt: result.expiresAt,
|
|
455
455
|
user: result.user,
|
|
456
456
|
accessToken: result.accessToken,
|
|
457
|
-
...(readRefreshToken(result) ? { refreshToken: readRefreshToken(result) } : {}),
|
|
458
457
|
},
|
|
459
458
|
result.user,
|
|
460
459
|
);
|
|
@@ -633,7 +632,6 @@ export class AccountDialogController {
|
|
|
633
632
|
name: claimed.user.name,
|
|
634
633
|
avatar: claimed.user.avatar ?? undefined,
|
|
635
634
|
};
|
|
636
|
-
const refreshToken = readRefreshToken(claimed);
|
|
637
635
|
try {
|
|
638
636
|
await this.completeSignIn(
|
|
639
637
|
{
|
|
@@ -642,7 +640,6 @@ export class AccountDialogController {
|
|
|
642
640
|
expiresAt: claimed.expiresAt ?? '',
|
|
643
641
|
user: minimalUser,
|
|
644
642
|
accessToken: claimed.accessToken,
|
|
645
|
-
...(refreshToken ? { refreshToken } : {}),
|
|
646
643
|
},
|
|
647
644
|
minimalUser,
|
|
648
645
|
);
|
|
@@ -656,7 +653,7 @@ export class AccountDialogController {
|
|
|
656
653
|
* by the shared-key, QR, and mint-switch paths so they cannot drift.
|
|
657
654
|
*/
|
|
658
655
|
private async completeSignIn(
|
|
659
|
-
session: SessionLoginResponse
|
|
656
|
+
session: SessionLoginResponse,
|
|
660
657
|
user: MinimalUserData,
|
|
661
658
|
): Promise<void> {
|
|
662
659
|
await this.commitAuthorizedSession(session, user);
|
|
@@ -675,7 +672,7 @@ export class AccountDialogController {
|
|
|
675
672
|
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
676
673
|
*/
|
|
677
674
|
private async commitAuthorizedSession(
|
|
678
|
-
session: SessionLoginResponse
|
|
675
|
+
session: SessionLoginResponse,
|
|
679
676
|
user: MinimalUserData,
|
|
680
677
|
): Promise<void> {
|
|
681
678
|
if (this.commitSession) {
|
|
@@ -751,17 +748,6 @@ export function createAccountDialogController(
|
|
|
751
748
|
// Local helpers
|
|
752
749
|
// ---------------------------------------------------------------------------
|
|
753
750
|
|
|
754
|
-
/**
|
|
755
|
-
* The rotating refresh-token family head is threaded on the runtime object by
|
|
756
|
-
* the trusted device-flow / switch lanes even though it is NOT on the typed
|
|
757
|
-
* return of `claimSessionByToken` / `switchToAccount`. Read it defensively so
|
|
758
|
-
* the commit funnel can persist a durable session.
|
|
759
|
-
*/
|
|
760
|
-
function readRefreshToken(value: unknown): string | undefined {
|
|
761
|
-
const token = (value as { refreshToken?: unknown }).refreshToken;
|
|
762
|
-
return typeof token === 'string' ? token : undefined;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
751
|
/** Current document URL on web; empty string where `location` is absent (native/SSR). */
|
|
766
752
|
function currentLocationHref(): string {
|
|
767
753
|
const location = (globalThis as { location?: { href?: string } }).location;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Persisted auth state — ONE shape, web + native.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `load / save / clear` interface plus
|
|
8
|
-
*
|
|
9
|
-
* platform storage API directly.
|
|
4
|
+
* The zero-cookie device transport persists the device credential per ORIGIN so
|
|
5
|
+
* a reload restores the session locally without a redirect: `deviceId` +
|
|
6
|
+
* `deviceSecret` mint a fresh access token via `POST /session/device/token`.
|
|
7
|
+
* This module is the storage seam: a tiny `load / save / clear` interface plus
|
|
8
|
+
* platform factories, so the cold boot (`coldBootV2`) and the unified re-mint
|
|
9
|
+
* handler (`refresh.ts`) never touch a platform storage API directly.
|
|
10
10
|
*
|
|
11
11
|
* Platform-agnostic — the native factory takes an INJECTED key/value store
|
|
12
12
|
* (`@oxyhq/services` passes a SecureStore-backed adapter) so `@oxyhq/core`
|
|
@@ -20,43 +20,36 @@
|
|
|
20
20
|
/**
|
|
21
21
|
* The persisted session credential set for a single origin.
|
|
22
22
|
*
|
|
23
|
-
* `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* `sessionId` + `userId` identify the owning device session and active account.
|
|
24
|
+
* `deviceId` + `deviceSecret` are the zero-cookie mint credential: the client
|
|
25
|
+
* presents BOTH at `POST /session/device/token` — the secret is the proof, the
|
|
26
|
+
* deviceId selects the device doc. The secret is rotated in-use: the mint returns
|
|
27
|
+
* `nextDeviceSecret`, which the cold boot / re-mint handler persist BEFORE
|
|
28
|
+
* planting the minted access token (multi-tab anti-loss).
|
|
27
29
|
*
|
|
28
|
-
* `accessToken` + `expiresAt` are OPTIONAL warm-boot fields. Persisting them
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* refresh token is the dominant secret either way).
|
|
30
|
+
* `accessToken` + `expiresAt` are OPTIONAL warm-boot fields. Persisting them lets
|
|
31
|
+
* the cold boot plant a still-valid access token on the very first paint WITHOUT
|
|
32
|
+
* a blocking mint round-trip — the proactive scheduler then rotates it in the
|
|
33
|
+
* background. They are a strict optimization: the store is fully functional (via
|
|
34
|
+
* `deviceSecret`) when they are absent or stale, and the access token is
|
|
35
|
+
* short-lived, so persisting it adds no exposure the already-persisted
|
|
36
|
+
* `deviceSecret` does not.
|
|
36
37
|
*/
|
|
37
38
|
export interface PersistedAuthState {
|
|
38
39
|
sessionId: string;
|
|
39
|
-
refreshToken: string;
|
|
40
40
|
userId: string;
|
|
41
|
-
deviceToken?: string;
|
|
42
41
|
/**
|
|
43
|
-
* The stable device identifier this session is bound to (
|
|
44
|
-
*
|
|
45
|
-
* `POST /session/device/token` mint presents BOTH
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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.
|
|
42
|
+
* The stable device identifier this session is bound to (zero-cookie
|
|
43
|
+
* transport). Persisted alongside {@link deviceSecret} because the
|
|
44
|
+
* `POST /session/device/token` mint presents BOTH. Sourced from every login
|
|
45
|
+
* lane (password / 2FA / QR claim / challenge verify) via the response's
|
|
46
|
+
* `deviceId`.
|
|
51
47
|
*/
|
|
52
48
|
deviceId?: string;
|
|
53
49
|
/**
|
|
54
|
-
* The rotating device secret (
|
|
55
|
-
*
|
|
56
|
-
* `POST /session/device/token
|
|
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`.
|
|
50
|
+
* The rotating device secret (zero-cookie transport). Possession of it mints a
|
|
51
|
+
* short access token for the device's active account via
|
|
52
|
+
* `POST /session/device/token`. Rotated in-use.
|
|
60
53
|
*/
|
|
61
54
|
deviceSecret?: string;
|
|
62
55
|
/** Optional warm-boot access token (short-lived; see interface docs). */
|
|
@@ -66,27 +59,15 @@ export interface PersistedAuthState {
|
|
|
66
59
|
}
|
|
67
60
|
|
|
68
61
|
/**
|
|
69
|
-
* The storage seam consumed by the cold boot and the
|
|
62
|
+
* The storage seam consumed by the cold boot and the re-mint handler. Async
|
|
70
63
|
* throughout so one interface fits both synchronous web `localStorage` and
|
|
71
|
-
* asynchronous native SecureStore/AsyncStorage.
|
|
72
|
-
*
|
|
73
|
-
* Two lifetimes:
|
|
74
|
-
* - The SESSION credential blob (`load`/`save`/`clear`) is per-sign-in and is
|
|
75
|
-
* wiped on `clear()` (sign-out).
|
|
76
|
-
* - The DEVICE token (`loadDeviceToken`/`saveDeviceToken`/`clearDeviceToken`)
|
|
77
|
-
* is long-lived device attribution that SURVIVES `clear()`: a signed-out
|
|
78
|
-
* browser is still the same device, and a later in-app (cross-apex,
|
|
79
|
-
* cookie-less) login sends this token so the new session joins the SAME
|
|
80
|
-
* server-side DeviceSession. Only an explicit device signout-all
|
|
81
|
-
* (`clearDeviceToken`) removes it.
|
|
64
|
+
* asynchronous native SecureStore/AsyncStorage. `clear()` (sign-out) wipes the
|
|
65
|
+
* per-sign-in credential blob.
|
|
82
66
|
*/
|
|
83
67
|
export interface AuthStateStore {
|
|
84
68
|
load(): Promise<PersistedAuthState | null>;
|
|
85
69
|
save(state: PersistedAuthState): Promise<void>;
|
|
86
70
|
clear(): Promise<void>;
|
|
87
|
-
loadDeviceToken(): Promise<string | null>;
|
|
88
|
-
saveDeviceToken(token: string): Promise<void>;
|
|
89
|
-
clearDeviceToken(): Promise<void>;
|
|
90
71
|
}
|
|
91
72
|
|
|
92
73
|
/**
|
|
@@ -106,13 +87,6 @@ export interface NativeKeyValueStorage {
|
|
|
106
87
|
*/
|
|
107
88
|
export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
108
89
|
|
|
109
|
-
/**
|
|
110
|
-
* Storage key for the long-lived device-attribution token. Separate from
|
|
111
|
-
* {@link AUTH_STATE_STORAGE_KEY} because it must OUTLIVE a session `clear()`
|
|
112
|
-
* (sign-out) — the device is unchanged across sign-ins.
|
|
113
|
-
*/
|
|
114
|
-
export const DEVICE_TOKEN_STORAGE_KEY = 'oxy.device.v1';
|
|
115
|
-
|
|
116
90
|
/**
|
|
117
91
|
* Parse + shape-validate a stored blob. Returns `null` for anything that is
|
|
118
92
|
* not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
|
|
@@ -134,22 +108,16 @@ function deserialize(raw: string | null): PersistedAuthState | null {
|
|
|
134
108
|
const candidate = parsed as Record<string, unknown>;
|
|
135
109
|
if (
|
|
136
110
|
typeof candidate.sessionId !== 'string' ||
|
|
137
|
-
typeof candidate.refreshToken !== 'string' ||
|
|
138
111
|
typeof candidate.userId !== 'string' ||
|
|
139
112
|
candidate.sessionId.length === 0 ||
|
|
140
|
-
candidate.refreshToken.length === 0 ||
|
|
141
113
|
candidate.userId.length === 0
|
|
142
114
|
) {
|
|
143
115
|
return null;
|
|
144
116
|
}
|
|
145
117
|
const state: PersistedAuthState = {
|
|
146
118
|
sessionId: candidate.sessionId,
|
|
147
|
-
refreshToken: candidate.refreshToken,
|
|
148
119
|
userId: candidate.userId,
|
|
149
120
|
};
|
|
150
|
-
if (typeof candidate.deviceToken === 'string' && candidate.deviceToken.length > 0) {
|
|
151
|
-
state.deviceToken = candidate.deviceToken;
|
|
152
|
-
}
|
|
153
121
|
if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
|
|
154
122
|
state.deviceId = candidate.deviceId;
|
|
155
123
|
}
|
|
@@ -173,7 +141,6 @@ function deserialize(raw: string | null): PersistedAuthState | null {
|
|
|
173
141
|
*/
|
|
174
142
|
export function createMemoryAuthStateStore(): AuthStateStore {
|
|
175
143
|
let current: PersistedAuthState | null = null;
|
|
176
|
-
let deviceToken: string | null = null;
|
|
177
144
|
return {
|
|
178
145
|
load: async () => current,
|
|
179
146
|
save: async (state) => {
|
|
@@ -182,13 +149,6 @@ export function createMemoryAuthStateStore(): AuthStateStore {
|
|
|
182
149
|
clear: async () => {
|
|
183
150
|
current = null;
|
|
184
151
|
},
|
|
185
|
-
loadDeviceToken: async () => deviceToken,
|
|
186
|
-
saveDeviceToken: async (token) => {
|
|
187
|
-
deviceToken = token;
|
|
188
|
-
},
|
|
189
|
-
clearDeviceToken: async () => {
|
|
190
|
-
deviceToken = null;
|
|
191
|
-
},
|
|
192
152
|
};
|
|
193
153
|
}
|
|
194
154
|
|
|
@@ -220,8 +180,8 @@ function safeGetLocalStorage(): Storage | null {
|
|
|
220
180
|
* for this page's lifetime — never throws on construction.
|
|
221
181
|
* - Individual `getItem`/`setItem`/`removeItem` are each wrapped: a read that
|
|
222
182
|
* throws yields `null`; a write that throws (quota, private mode) is
|
|
223
|
-
* swallowed. The
|
|
224
|
-
*
|
|
183
|
+
* swallowed. The re-mint lane treats a failed persist as "no durable state"
|
|
184
|
+
* rather than crashing.
|
|
225
185
|
*/
|
|
226
186
|
export function createWebAuthStateStore(): AuthStateStore {
|
|
227
187
|
const storage = safeGetLocalStorage();
|
|
@@ -233,7 +193,6 @@ export function createWebAuthStateStore(): AuthStateStore {
|
|
|
233
193
|
// means "never written this session" → fall back to storage; any set value
|
|
234
194
|
// (including `null` after clear) is authoritative and preferred over storage.
|
|
235
195
|
let sessionMirror: PersistedAuthState | null | undefined;
|
|
236
|
-
let deviceTokenMirror: string | null | undefined;
|
|
237
196
|
return {
|
|
238
197
|
load: async () => {
|
|
239
198
|
if (sessionMirror !== undefined) {
|
|
@@ -262,32 +221,6 @@ export function createWebAuthStateStore(): AuthStateStore {
|
|
|
262
221
|
// Non-fatal — see save().
|
|
263
222
|
}
|
|
264
223
|
},
|
|
265
|
-
loadDeviceToken: async () => {
|
|
266
|
-
if (deviceTokenMirror !== undefined) {
|
|
267
|
-
return deviceTokenMirror;
|
|
268
|
-
}
|
|
269
|
-
try {
|
|
270
|
-
return storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
271
|
-
} catch {
|
|
272
|
-
return null;
|
|
273
|
-
}
|
|
274
|
-
},
|
|
275
|
-
saveDeviceToken: async (token) => {
|
|
276
|
-
deviceTokenMirror = token;
|
|
277
|
-
try {
|
|
278
|
-
storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
|
|
279
|
-
} catch {
|
|
280
|
-
// Non-fatal — see save().
|
|
281
|
-
}
|
|
282
|
-
},
|
|
283
|
-
clearDeviceToken: async () => {
|
|
284
|
-
deviceTokenMirror = null;
|
|
285
|
-
try {
|
|
286
|
-
storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
287
|
-
} catch {
|
|
288
|
-
// Non-fatal — see save().
|
|
289
|
-
}
|
|
290
|
-
},
|
|
291
224
|
};
|
|
292
225
|
}
|
|
293
226
|
|
|
@@ -303,7 +236,6 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
|
|
|
303
236
|
// Same in-memory mirror as the web store — a locked/failed SecureStore write
|
|
304
237
|
// must not silently lose the session for the app's lifetime.
|
|
305
238
|
let sessionMirror: PersistedAuthState | null | undefined;
|
|
306
|
-
let deviceTokenMirror: string | null | undefined;
|
|
307
239
|
return {
|
|
308
240
|
load: async () => {
|
|
309
241
|
if (sessionMirror !== undefined) {
|
|
@@ -331,31 +263,5 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
|
|
|
331
263
|
// Non-fatal.
|
|
332
264
|
}
|
|
333
265
|
},
|
|
334
|
-
loadDeviceToken: async () => {
|
|
335
|
-
if (deviceTokenMirror !== undefined) {
|
|
336
|
-
return deviceTokenMirror;
|
|
337
|
-
}
|
|
338
|
-
try {
|
|
339
|
-
return await storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
340
|
-
} catch {
|
|
341
|
-
return null;
|
|
342
|
-
}
|
|
343
|
-
},
|
|
344
|
-
saveDeviceToken: async (token) => {
|
|
345
|
-
deviceTokenMirror = token;
|
|
346
|
-
try {
|
|
347
|
-
await storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
|
|
348
|
-
} catch {
|
|
349
|
-
// Non-fatal.
|
|
350
|
-
}
|
|
351
|
-
},
|
|
352
|
-
clearDeviceToken: async () => {
|
|
353
|
-
deviceTokenMirror = null;
|
|
354
|
-
try {
|
|
355
|
-
await storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
356
|
-
} catch {
|
|
357
|
-
// Non-fatal.
|
|
358
|
-
}
|
|
359
|
-
},
|
|
360
266
|
};
|
|
361
267
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OxyServices } from '../OxyServices';
|
|
2
|
-
import { SessionClient, type
|
|
2
|
+
import { SessionClient, type TokenTransport } from './SessionClient';
|
|
3
3
|
import type { SocketIOFactory } from './socketLoader';
|
|
4
4
|
import { createSessionClientHost } from './sessionClientHost';
|
|
5
5
|
|
|
@@ -29,18 +29,11 @@ export function createSessionClient(
|
|
|
29
29
|
oxyServices: OxyServices,
|
|
30
30
|
transport: TokenTransport,
|
|
31
31
|
socketFactory?: SocketIOFactory,
|
|
32
|
-
/**
|
|
33
|
-
* Optional signed-out realtime wiring: `signedOutSocketAuth` (open the socket
|
|
34
|
-
* while signed out so an idle tab receives its device pushes) and
|
|
35
|
-
* `onSessionAppeared` (self-acquire when a sibling signs in). See
|
|
36
|
-
* {@link SessionClientOptions}.
|
|
37
|
-
*/
|
|
38
|
-
extra?: Pick<SessionClientOptions, 'signedOutSocketAuth' | 'onSessionAppeared'>,
|
|
39
32
|
): {
|
|
40
33
|
client: SessionClient;
|
|
41
34
|
host: ReturnType<typeof createSessionClientHost>;
|
|
42
35
|
} {
|
|
43
36
|
const host = createSessionClientHost(oxyServices);
|
|
44
|
-
const client = new SessionClient(host, { transport, socketFactory
|
|
37
|
+
const client = new SessionClient(host, { transport, socketFactory });
|
|
45
38
|
return { client, host };
|
|
46
39
|
}
|