@oxyhq/services 18.0.0 → 19.0.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/lib/commonjs/ui/context/OxyContext.js +52 -83
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +15 -18
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/commonjs/ui/session/authStore.js +12 -50
- package/lib/commonjs/ui/session/authStore.js.map +1 -1
- package/lib/commonjs/ui/session/createSessionClient.js +6 -11
- package/lib/commonjs/ui/session/createSessionClient.js.map +1 -1
- package/lib/commonjs/ui/session/tokenTransport.js +3 -4
- package/lib/commonjs/ui/session/tokenTransport.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +52 -83
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js +15 -18
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/session/authStore.js +13 -51
- package/lib/module/ui/session/authStore.js.map +1 -1
- package/lib/module/ui/session/createSessionClient.js +6 -11
- package/lib/module/ui/session/createSessionClient.js.map +1 -1
- package/lib/module/ui/session/tokenTransport.js +3 -4
- package/lib/module/ui/session/tokenTransport.js.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +15 -6
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +4 -5
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/authStore.d.ts +11 -15
- package/lib/typescript/commonjs/ui/session/authStore.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +6 -10
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +3 -4
- package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts +15 -6
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +4 -5
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/authStore.d.ts +11 -15
- package/lib/typescript/module/ui/session/authStore.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/createSessionClient.d.ts +6 -10
- package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/tokenTransport.d.ts +3 -4
- package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/ui/context/OxyContext.tsx +61 -92
- package/src/ui/context/__tests__/OxyContext.coldBoot.test.tsx +13 -12
- package/src/ui/context/hooks/useAuthOperations.ts +18 -20
- package/src/ui/session/__tests__/createSessionClient.test.ts +3 -1
- package/src/ui/session/__tests__/tokenTransport.test.ts +6 -5
- package/src/ui/session/authStore.ts +12 -55
- package/src/ui/session/createSessionClient.ts +5 -10
- package/src/ui/session/tokenTransport.ts +3 -4
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* `OxyContextProvider` cold-boot opt-out (IdP mode).
|
|
3
3
|
*
|
|
4
4
|
* The provider is the ecosystem's device-first session authority by default:
|
|
5
|
-
* it runs `runSessionColdBoot` on mount
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* it runs `runSessionColdBoot` on mount. The IdP host (`auth.oxy.so`) is NOT a
|
|
6
|
+
* session authority, so it mounts the provider with `coldBoot={false}` — the
|
|
7
|
+
* cold boot must never run (so the bearer-authenticated device socket never
|
|
8
|
+
* starts), yet interactive sign-in must still commit a normal session on that
|
|
9
|
+
* origin.
|
|
10
10
|
*/
|
|
11
11
|
import { render, act, waitFor } from '@testing-library/react';
|
|
12
12
|
import type { ReactNode } from 'react';
|
|
@@ -105,7 +105,8 @@ describe('OxyContextProvider coldBoot opt-out (IdP mode)', () => {
|
|
|
105
105
|
// No boot spinner: private-API readiness settles to a definitive "no".
|
|
106
106
|
expect(getState()?.isPrivateApiPending).toBe(false);
|
|
107
107
|
expect(getState()?.canUsePrivateApi).toBe(false);
|
|
108
|
-
//
|
|
108
|
+
// IdP mode never runs the cold boot and never signs in, so the device
|
|
109
|
+
// socket is never started.
|
|
109
110
|
expect(lastSessionClient().start).not.toHaveBeenCalled();
|
|
110
111
|
});
|
|
111
112
|
|
|
@@ -116,7 +117,7 @@ describe('OxyContextProvider coldBoot opt-out (IdP mode)', () => {
|
|
|
116
117
|
deviceId: 'd-1',
|
|
117
118
|
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
118
119
|
accessToken: 'access-1',
|
|
119
|
-
|
|
120
|
+
deviceSecret: 'ds-1',
|
|
120
121
|
user: { id: 'u-1', username: 'alice' },
|
|
121
122
|
};
|
|
122
123
|
jest.spyOn(oxy, 'passwordSignIn').mockResolvedValue(loginResult);
|
|
@@ -144,11 +145,11 @@ describe('OxyContextProvider coldBoot opt-out (IdP mode)', () => {
|
|
|
144
145
|
expect(getState()?.isAuthenticated).toBe(true);
|
|
145
146
|
});
|
|
146
147
|
expect(getState()?.user?.id).toBe('u-1');
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
);
|
|
148
|
+
// Zero-cookie cutover: no `deviceToken` is threaded into `passwordSignIn`.
|
|
149
|
+
expect(oxy.passwordSignIn).toHaveBeenCalledWith('alice', 'secret', {
|
|
150
|
+
deviceName: undefined,
|
|
151
|
+
deviceFingerprint: undefined,
|
|
152
|
+
});
|
|
152
153
|
});
|
|
153
154
|
|
|
154
155
|
it('default (coldBoot omitted): runs the device-first cold boot', async () => {
|
|
@@ -15,10 +15,9 @@ export interface UseAuthOperationsOptions {
|
|
|
15
15
|
/**
|
|
16
16
|
* The device-first persisted auth-state store. On EXPLICIT full sign-out the
|
|
17
17
|
* session blob is cleared (`store.clear()`) so a reload's cold boot finds no
|
|
18
|
-
*
|
|
19
|
-
* persisted so the next boot
|
|
20
|
-
*
|
|
21
|
-
* cleared here.
|
|
18
|
+
* device credential to restore; on sign-in the returned zero-cookie device
|
|
19
|
+
* credential (`deviceId` + `deviceSecret`) is persisted so the next boot
|
|
20
|
+
* warm-restores without a redirect.
|
|
22
21
|
*/
|
|
23
22
|
store: AuthStateStore;
|
|
24
23
|
activeSessionId: string | null;
|
|
@@ -61,7 +60,7 @@ const LOGOUT_ERROR_CODE = 'LOGOUT_ERROR';
|
|
|
61
60
|
const LOGOUT_ALL_ERROR_CODE = 'LOGOUT_ALL_ERROR';
|
|
62
61
|
|
|
63
62
|
/**
|
|
64
|
-
* Clear the persisted
|
|
63
|
+
* Clear the persisted device credential on an explicit full sign-out.
|
|
65
64
|
* Best-effort and non-blocking: sign-out must never fail because a storage
|
|
66
65
|
* write threw. Exported so `OxyContext`'s zero-account branch (a REMOTE full
|
|
67
66
|
* sign-out pushed over the socket) runs the EXACT same cleanup as the local
|
|
@@ -136,22 +135,21 @@ export const useAuthOperations = ({
|
|
|
136
135
|
deviceFingerprint,
|
|
137
136
|
);
|
|
138
137
|
|
|
139
|
-
// Persist the
|
|
138
|
+
// Persist the durable device credential so a reload warm-restores this
|
|
140
139
|
// session without a redirect. `verifyChallenge` plants the access token
|
|
141
|
-
// internally; when the
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// `
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
140
|
+
// internally; when the response also carries the zero-cookie device
|
|
141
|
+
// credential (`deviceId` + `deviceSecret`), persist the durable blob so the
|
|
142
|
+
// next cold boot re-mints an access token from it. Best-effort — a failed
|
|
143
|
+
// persist never fails the sign-in. `SessionLoginResponse` does not type
|
|
144
|
+
// `deviceSecret`; read it defensively from the runtime payload.
|
|
145
|
+
const deviceSecret = (sessionResponse as { deviceSecret?: string }).deviceSecret;
|
|
146
|
+
if (sessionResponse.deviceId && deviceSecret) {
|
|
148
147
|
try {
|
|
149
|
-
const deviceToken = (await store.loadDeviceToken()) ?? undefined;
|
|
150
148
|
await store.save({
|
|
151
149
|
sessionId: sessionResponse.sessionId,
|
|
152
|
-
refreshToken,
|
|
153
150
|
userId: sessionResponse.user.id,
|
|
154
|
-
|
|
151
|
+
deviceId: sessionResponse.deviceId,
|
|
152
|
+
deviceSecret,
|
|
155
153
|
...(sessionResponse.accessToken ? { accessToken: sessionResponse.accessToken } : {}),
|
|
156
154
|
...(sessionResponse.expiresAt ? { expiresAt: sessionResponse.expiresAt } : {}),
|
|
157
155
|
});
|
|
@@ -302,7 +300,7 @@ export const useAuthOperations = ({
|
|
|
302
300
|
|
|
303
301
|
if (sessionToLogout === activeSessionId && remainingAccounts.length === 0) {
|
|
304
302
|
// Genuine FULL sign-out (no sessions remain): clear the persisted
|
|
305
|
-
//
|
|
303
|
+
// device credential so a reload's cold boot finds nothing to restore,
|
|
306
304
|
// then tear down local state.
|
|
307
305
|
clearPersistedAuthSafe(store, logger);
|
|
308
306
|
await clearSessionState();
|
|
@@ -356,9 +354,9 @@ export const useAuthOperations = ({
|
|
|
356
354
|
// `{ all: true }`) — replaces the bearer `logoutAllSessions` +
|
|
357
355
|
// web-cookie `logoutAllSessionsViaCookie` pair.
|
|
358
356
|
await sessionClient.signOut({ all: true });
|
|
359
|
-
// logoutAll is ALWAYS a full sign-out: clear the persisted
|
|
360
|
-
// so the next cold boot finds no session to restore, then tear
|
|
361
|
-
//
|
|
357
|
+
// logoutAll is ALWAYS a full sign-out: clear the persisted device
|
|
358
|
+
// credential so the next cold boot finds no session to restore, then tear
|
|
359
|
+
// down local state.
|
|
362
360
|
clearPersistedAuthSafe(store, logger);
|
|
363
361
|
await clearSessionState();
|
|
364
362
|
} catch (error) {
|
|
@@ -16,12 +16,14 @@ jest.mock('socket.io-client', () => ({ __esModule: true, io: (...args: unknown[]
|
|
|
16
16
|
|
|
17
17
|
import { createSessionClient } from '../createSessionClient';
|
|
18
18
|
|
|
19
|
+
// Sockets are bearer-only: `start()` opens a socket only when a bearer token is
|
|
20
|
+
// held. This mock reports one so the wiring test exercises the socket factory.
|
|
19
21
|
function fakeOxy() {
|
|
20
22
|
const listeners = new Set<(t: string | null) => void>();
|
|
21
23
|
return {
|
|
22
24
|
makeRequest: jest.fn().mockResolvedValue(undefined),
|
|
23
25
|
getBaseURL: jest.fn().mockReturnValue('https://api.oxy.so'),
|
|
24
|
-
getAccessToken: jest.fn().mockReturnValue(
|
|
26
|
+
getAccessToken: jest.fn().mockReturnValue('bearer.jwt.token'),
|
|
25
27
|
setTokens: jest.fn(),
|
|
26
28
|
onTokensChanged: jest.fn((l: (t: string | null) => void) => {
|
|
27
29
|
listeners.add(l);
|
|
@@ -2,11 +2,12 @@ import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
|
2
2
|
import { logger, createMemoryAuthStateStore, refreshPersistedSession } from '@oxyhq/core';
|
|
3
3
|
import { createTokenTransport } from '../tokenTransport';
|
|
4
4
|
|
|
5
|
-
// The device-first transport mints a fallback token
|
|
6
|
-
//
|
|
7
|
-
// there is no `silentSignIn`/`signInWithSharedIdentity` arm
|
|
8
|
-
// one core function so the transport's own coalescing /
|
|
9
|
-
// exercised in isolation; keep the real `logger` +
|
|
5
|
+
// The device-first transport mints a fallback token from the persisted
|
|
6
|
+
// zero-cookie device credential via `refreshPersistedSession` (the ONE unified
|
|
7
|
+
// refresh path) — there is no `silentSignIn`/`signInWithSharedIdentity` arm
|
|
8
|
+
// anymore. Mock that one core function so the transport's own coalescing /
|
|
9
|
+
// logging contract can be exercised in isolation; keep the real `logger` +
|
|
10
|
+
// memory store.
|
|
10
11
|
jest.mock('@oxyhq/core', () => {
|
|
11
12
|
const actual = jest.requireActual('@oxyhq/core');
|
|
12
13
|
return { __esModule: true, ...actual, refreshPersistedSession: jest.fn() };
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Platform `AuthStateStore` for `@oxyhq/services`.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The zero-cookie device model persists the durable device credential
|
|
5
|
+
* (`deviceId` + `deviceSecret`) per origin (web) / per device (native), and the
|
|
6
|
+
* SDK re-mints the access token from that credential on cold boot. `@oxyhq/core`
|
|
7
|
+
* owns the store shape + logic (`createWebAuthStateStore` /
|
|
8
|
+
* `createNativeAuthStateStore`); this module only supplies the platform storage
|
|
9
|
+
* seam:
|
|
9
10
|
*
|
|
10
11
|
* - web → `createWebAuthStateStore()` (localStorage, in-memory fallback).
|
|
11
|
-
* - native → `createNativeAuthStateStore(secureKV)` for the SESSION blob
|
|
12
|
-
*
|
|
13
|
-
* long-lived DEVICE token delegated to `KeyManager`'s shared keychain
|
|
14
|
-
* (`group.so.oxy.shared`) instead of per-app storage — so an in-app login on
|
|
15
|
-
* any native Oxy app attributes to the SAME server-side DeviceSession.
|
|
12
|
+
* - native → `createNativeAuthStateStore(secureKV)` for the SESSION blob over
|
|
13
|
+
* `expo-secure-store` (AsyncStorage fallback).
|
|
16
14
|
*
|
|
17
15
|
* `expo-secure-store` is loaded via a runtime-computed dynamic import (the same
|
|
18
16
|
* optional-native-module pattern `OxyProvider` uses for netinfo / keyboard
|
|
@@ -20,12 +18,10 @@
|
|
|
20
18
|
* back to AsyncStorage rather than crashing.
|
|
21
19
|
*/
|
|
22
20
|
import {
|
|
23
|
-
KeyManager,
|
|
24
21
|
createWebAuthStateStore,
|
|
25
22
|
createNativeAuthStateStore,
|
|
26
23
|
type AuthStateStore,
|
|
27
24
|
type NativeKeyValueStorage,
|
|
28
|
-
logger,
|
|
29
25
|
} from '@oxyhq/core';
|
|
30
26
|
import { createPlatformStorage, isReactNative } from '../utils/storageHelpers';
|
|
31
27
|
|
|
@@ -112,53 +108,14 @@ function createNativeSecureKeyValueStorage(): NativeKeyValueStorage {
|
|
|
112
108
|
/**
|
|
113
109
|
* Build the platform {@link AuthStateStore} for this runtime.
|
|
114
110
|
*
|
|
115
|
-
* Native
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* a locked/failed shared-keychain read degrades to "no device token" rather
|
|
119
|
-
* than throwing out of cold boot / refresh.
|
|
111
|
+
* Native persists the SESSION blob (`deviceId` + `deviceSecret` + cached access
|
|
112
|
+
* token) per-app in SecureStore; the SDK re-mints the access token from the
|
|
113
|
+
* device credential on the next cold boot.
|
|
120
114
|
*/
|
|
121
115
|
export function createPlatformAuthStateStore(): AuthStateStore {
|
|
122
116
|
if (!isReactNative()) {
|
|
123
117
|
return createWebAuthStateStore();
|
|
124
118
|
}
|
|
125
119
|
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
...base,
|
|
129
|
-
loadDeviceToken: async () => {
|
|
130
|
-
try {
|
|
131
|
-
return await KeyManager.getSharedDeviceToken();
|
|
132
|
-
} catch (error) {
|
|
133
|
-
logger.debug(
|
|
134
|
-
'Shared device-token read failed (treating as none)',
|
|
135
|
-
{ component: 'authStore', method: 'loadDeviceToken' },
|
|
136
|
-
error,
|
|
137
|
-
);
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
saveDeviceToken: async (token) => {
|
|
142
|
-
try {
|
|
143
|
-
await KeyManager.setSharedDeviceToken(token);
|
|
144
|
-
} catch (error) {
|
|
145
|
-
logger.debug(
|
|
146
|
-
'Shared device-token write failed (non-fatal)',
|
|
147
|
-
{ component: 'authStore', method: 'saveDeviceToken' },
|
|
148
|
-
error,
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
clearDeviceToken: async () => {
|
|
153
|
-
try {
|
|
154
|
-
await KeyManager.clearSharedDeviceToken();
|
|
155
|
-
} catch (error) {
|
|
156
|
-
logger.debug(
|
|
157
|
-
'Shared device-token clear failed (non-fatal)',
|
|
158
|
-
{ component: 'authStore', method: 'clearDeviceToken' },
|
|
159
|
-
error,
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
},
|
|
163
|
-
};
|
|
120
|
+
return createNativeAuthStateStore(createNativeSecureKeyValueStorage());
|
|
164
121
|
}
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
createSessionClientHost,
|
|
5
5
|
type AuthStateStore,
|
|
6
6
|
type OxyServices,
|
|
7
|
-
type SessionClientOptions,
|
|
8
7
|
} from '@oxyhq/core';
|
|
9
8
|
import { createTokenTransport } from './tokenTransport';
|
|
10
9
|
|
|
@@ -20,31 +19,27 @@ import { createTokenTransport } from './tokenTransport';
|
|
|
20
19
|
* core's lazy dynamic import of a bare specifier — bundler-fragile in
|
|
21
20
|
* Metro/Expo-web against the published core dist.
|
|
22
21
|
* - the device-first {@link createTokenTransport}, which mints a fallback token
|
|
23
|
-
*
|
|
22
|
+
* from the persisted device credential (`store` → `deviceId` + `deviceSecret`).
|
|
24
23
|
*
|
|
25
24
|
* `onUnauthenticated` fires when an applied device state has zero accounts (a
|
|
26
25
|
* device signout-all): the provider clears the persisted store + local state so
|
|
27
26
|
* a reload does not try to restore a dead session. The host is returned
|
|
28
27
|
* alongside the client so the caller can call `host.setCurrentAccountId(...)`
|
|
29
28
|
* as the active account changes.
|
|
29
|
+
*
|
|
30
|
+
* The realtime socket is bearer-only: a signed-out tab cannot join any device
|
|
31
|
+
* room, so there is no signed-out socket wiring.
|
|
30
32
|
*/
|
|
31
33
|
export function createSessionClient(
|
|
32
34
|
oxyServices: OxyServices,
|
|
33
35
|
store: AuthStateStore,
|
|
34
36
|
onUnauthenticated?: () => void,
|
|
35
|
-
/**
|
|
36
|
-
* Optional signed-out realtime wiring: `signedOutSocketAuth` (open the socket
|
|
37
|
-
* while signed out — web returns `true` to ride the `oxy_device` cookie,
|
|
38
|
-
* native returns the shared device token) and `onSessionAppeared` (self-acquire
|
|
39
|
-
* when a sibling signs in on this device).
|
|
40
|
-
*/
|
|
41
|
-
extra?: Pick<SessionClientOptions, 'signedOutSocketAuth' | 'onSessionAppeared'>,
|
|
42
37
|
): {
|
|
43
38
|
client: SessionClient;
|
|
44
39
|
host: ReturnType<typeof createSessionClientHost>;
|
|
45
40
|
} {
|
|
46
41
|
const host = createSessionClientHost(oxyServices);
|
|
47
42
|
const transport = createTokenTransport(oxyServices, store);
|
|
48
|
-
const client = new SessionClient(host, { transport, socketFactory: io, onUnauthenticated
|
|
43
|
+
const client = new SessionClient(host, { transport, socketFactory: io, onUnauthenticated });
|
|
49
44
|
return { client, host };
|
|
50
45
|
}
|
|
@@ -13,10 +13,9 @@ import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
|
13
13
|
* `ensureActiveToken` is the fallback the client uses when a `session_state`
|
|
14
14
|
* push arrived WITHOUT an embedded `activeToken` and the app currently holds no
|
|
15
15
|
* bearer. It mints one through the ONE unified refresh path
|
|
16
|
-
* (`refreshPersistedSession`)
|
|
17
|
-
* (`
|
|
18
|
-
*
|
|
19
|
-
* persisted refresh token is the durable web credential.
|
|
16
|
+
* (`refreshPersistedSession`), which presents the persisted zero-cookie device
|
|
17
|
+
* credential (`deviceId` + `deviceSecret`) at `POST /session/device/token`. The
|
|
18
|
+
* persisted device credential is the sole durable restore credential.
|
|
20
19
|
*
|
|
21
20
|
* Concurrent pushes coalesce onto one in-flight mint. A failure is logged and
|
|
22
21
|
* swallowed: this method must never throw out (it runs inside the socket state
|