@oxyhq/services 10.0.0 → 10.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/lib/commonjs/ui/components/SignInModal.js +24 -11
- package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +5 -1
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/screens/OxyAuthScreen.js +66 -25
- package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/commonjs/utils/deviceFlowSignIn.js +55 -0
- package/lib/commonjs/utils/deviceFlowSignIn.js.map +1 -0
- package/lib/module/ui/components/SignInModal.js +24 -11
- package/lib/module/ui/components/SignInModal.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +5 -1
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/screens/OxyAuthScreen.js +66 -25
- package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/module/utils/deviceFlowSignIn.js +51 -0
- package/lib/module/utils/deviceFlowSignIn.js.map +1 -0
- package/lib/typescript/commonjs/ui/components/SignInModal.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useServicesMutations.d.ts +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useServicesMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts +61 -0
- package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useServicesMutations.d.ts +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useServicesMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
- package/lib/typescript/module/utils/deviceFlowSignIn.d.ts +61 -0
- package/lib/typescript/module/utils/deviceFlowSignIn.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/ui/components/SignInModal.tsx +25 -11
- package/src/ui/context/OxyContext.tsx +8 -4
- package/src/ui/screens/OxyAuthScreen.tsx +67 -25
- package/src/utils/__tests__/deviceFlowSignIn.test.ts +104 -0
- package/src/utils/deviceFlowSignIn.ts +76 -0
|
@@ -35,6 +35,7 @@ import { Loading } from '@oxyhq/bloom/loading';
|
|
|
35
35
|
import { useOxy } from '../context/OxyContext';
|
|
36
36
|
import OxyLogo from './OxyLogo';
|
|
37
37
|
import { createDebugLogger } from '@oxyhq/core';
|
|
38
|
+
import { completeDeviceFlowSignIn } from '../../utils/deviceFlowSignIn';
|
|
38
39
|
|
|
39
40
|
const debug = createDebugLogger('SignInModal');
|
|
40
41
|
|
|
@@ -153,16 +154,18 @@ const SignInModal: React.FC = () => {
|
|
|
153
154
|
isProcessingRef.current = true;
|
|
154
155
|
|
|
155
156
|
try {
|
|
156
|
-
//
|
|
157
|
-
// session.
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// Now the SDK has a bearer token, the normal session
|
|
162
|
-
// management path can hydrate state from the sessionId.
|
|
163
|
-
if (switchSession) {
|
|
164
|
-
await switchSession(sessionId);
|
|
157
|
+
// Claim the first access token with the secret sessionToken, then
|
|
158
|
+
// hydrate the session. Shared with the native `OxyAuthScreen` via
|
|
159
|
+
// `completeDeviceFlowSignIn` so the two paths cannot drift.
|
|
160
|
+
if (!switchSession) {
|
|
161
|
+
throw new Error('Session management unavailable');
|
|
165
162
|
}
|
|
163
|
+
await completeDeviceFlowSignIn({
|
|
164
|
+
oxyServices,
|
|
165
|
+
sessionId,
|
|
166
|
+
sessionToken,
|
|
167
|
+
switchSession,
|
|
168
|
+
});
|
|
166
169
|
|
|
167
170
|
hideSignInModal();
|
|
168
171
|
} catch (err) {
|
|
@@ -230,8 +233,14 @@ const SignInModal: React.FC = () => {
|
|
|
230
233
|
});
|
|
231
234
|
}, [oxyServices, handleAuthSuccess, cleanup]);
|
|
232
235
|
|
|
233
|
-
// Start polling for authorization
|
|
236
|
+
// Start polling for authorization.
|
|
237
|
+
//
|
|
238
|
+
// Idempotent: if a poll interval is already running this is a no-op, so the
|
|
239
|
+
// `connect_error` path (which also calls this) cannot stack a second
|
|
240
|
+
// interval on top of the always-on poll started in `generateAuthSession`.
|
|
234
241
|
const startPolling = useCallback((sessionToken: string) => {
|
|
242
|
+
if (pollingIntervalRef.current) return;
|
|
243
|
+
|
|
235
244
|
pollingIntervalRef.current = setInterval(async () => {
|
|
236
245
|
if (isProcessingRef.current) return;
|
|
237
246
|
|
|
@@ -289,13 +298,18 @@ const SignInModal: React.FC = () => {
|
|
|
289
298
|
|
|
290
299
|
setAuthSession({ sessionToken, expiresAt });
|
|
291
300
|
setIsWaiting(true);
|
|
301
|
+
// Socket is the fast path; the poll is a transport-independent
|
|
302
|
+
// backstop that guarantees completion even if the socket connects
|
|
303
|
+
// but silently never delivers auth_update (RN transport /
|
|
304
|
+
// idle-timeout).
|
|
292
305
|
connectSocket(sessionToken);
|
|
306
|
+
startPolling(sessionToken);
|
|
293
307
|
} catch (err: unknown) {
|
|
294
308
|
setError((err instanceof Error ? err.message : null) || 'Failed to create auth session');
|
|
295
309
|
} finally {
|
|
296
310
|
setIsLoading(false);
|
|
297
311
|
}
|
|
298
|
-
}, [oxyServices, connectSocket, clientId]);
|
|
312
|
+
}, [oxyServices, connectSocket, startPolling, clientId]);
|
|
299
313
|
|
|
300
314
|
// Generate a cryptographically random session token.
|
|
301
315
|
// 16 random bytes -> 32 hex chars (128 bits of entropy) — unguessable.
|
|
@@ -97,7 +97,7 @@ export interface OxyContextState {
|
|
|
97
97
|
// Session management
|
|
98
98
|
logout: (targetSessionId?: string) => Promise<void>;
|
|
99
99
|
logoutAll: () => Promise<void>;
|
|
100
|
-
switchSession: (sessionId: string) => Promise<
|
|
100
|
+
switchSession: (sessionId: string) => Promise<User>;
|
|
101
101
|
removeSession: (sessionId: string) => Promise<void>;
|
|
102
102
|
refreshSessions: () => Promise<void>;
|
|
103
103
|
setLanguage: (languageId: string) => Promise<void>;
|
|
@@ -1555,8 +1555,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1555
1555
|
});
|
|
1556
1556
|
|
|
1557
1557
|
const switchSessionForContext = useCallback(
|
|
1558
|
-
async (sessionId: string): Promise<
|
|
1559
|
-
|
|
1558
|
+
async (sessionId: string): Promise<User> => {
|
|
1559
|
+
// Propagate the activated user so callers (the device-flow sign-in,
|
|
1560
|
+
// `useSwitchSession`'s cache write, account chooser) receive it. The
|
|
1561
|
+
// underlying session-management `switchSession` already resolves the
|
|
1562
|
+
// `User`; the previous `Promise<void>` wrapper discarded it.
|
|
1563
|
+
return switchSession(sessionId);
|
|
1560
1564
|
},
|
|
1561
1565
|
[switchSession],
|
|
1562
1566
|
);
|
|
@@ -1776,7 +1780,7 @@ const LOADING_STATE: OxyContextState = {
|
|
|
1776
1780
|
handlePopupSession: () => rejectMissingProvider<void>(),
|
|
1777
1781
|
logout: () => rejectMissingProvider<void>(),
|
|
1778
1782
|
logoutAll: () => rejectMissingProvider<void>(),
|
|
1779
|
-
switchSession: () => rejectMissingProvider<
|
|
1783
|
+
switchSession: () => rejectMissingProvider<User>(),
|
|
1780
1784
|
removeSession: () => rejectMissingProvider<void>(),
|
|
1781
1785
|
refreshSessions: () => rejectMissingProvider<void>(),
|
|
1782
1786
|
setLanguage: () => rejectMissingProvider<void>(),
|
|
@@ -30,6 +30,7 @@ import { useOxy } from '../context/OxyContext';
|
|
|
30
30
|
import QRCode from 'react-native-qrcode-svg';
|
|
31
31
|
import OxyLogo from '../components/OxyLogo';
|
|
32
32
|
import { createDebugLogger } from '@oxyhq/core';
|
|
33
|
+
import { completeDeviceFlowSignIn } from '../../utils/deviceFlowSignIn';
|
|
33
34
|
|
|
34
35
|
const debug = createDebugLogger('OxyAuthScreen');
|
|
35
36
|
|
|
@@ -119,7 +120,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
119
120
|
theme,
|
|
120
121
|
}) => {
|
|
121
122
|
const bloomTheme = useTheme();
|
|
122
|
-
const { oxyServices,
|
|
123
|
+
const { oxyServices, switchSession, clientId } = useOxy();
|
|
123
124
|
|
|
124
125
|
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
|
|
125
126
|
const [isLoading, setIsLoading] = useState(true);
|
|
@@ -132,25 +133,42 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
132
133
|
const isProcessingRef = useRef(false);
|
|
133
134
|
const linkingHandledRef = useRef(false);
|
|
134
135
|
|
|
135
|
-
// Handle successful authorization
|
|
136
|
-
|
|
136
|
+
// Handle successful authorization.
|
|
137
|
+
//
|
|
138
|
+
// The auth-session socket / poll (or deep-link return) hands us the
|
|
139
|
+
// authorized `sessionId`. Before any session-management code can touch it we
|
|
140
|
+
// MUST first exchange the secret `sessionToken` (held only by this client,
|
|
141
|
+
// generated for THIS flow) for the first access token via
|
|
142
|
+
// `claimSessionByToken` — the device-flow equivalent of OAuth's
|
|
143
|
+
// code-for-token exchange (RFC 8628 §3.4).
|
|
144
|
+
//
|
|
145
|
+
// Without that exchange the SDK has no bearer token, so the subsequent
|
|
146
|
+
// `switchSession` -> `getTokenBySession` call (`GET /session/token/:id`) 401s
|
|
147
|
+
// against the C1-hardened API — the session is authorized server-side but the
|
|
148
|
+
// app never becomes authenticated and the sheet sits "Waiting for
|
|
149
|
+
// authorization..." forever. Once `claimSessionByToken` plants the tokens in
|
|
150
|
+
// the HttpService, the rest of the session wiring flows through the normal
|
|
151
|
+
// `switchSession` path. This mirrors `SignInModal`'s web flow exactly.
|
|
152
|
+
const handleAuthSuccess = useCallback(async (sessionId: string, sessionToken: string) => {
|
|
137
153
|
if (isProcessingRef.current) return;
|
|
138
154
|
isProcessingRef.current = true;
|
|
139
155
|
|
|
140
156
|
try {
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
157
|
+
// Claim the first access token with the secret sessionToken, then
|
|
158
|
+
// hydrate the session. The claim step is what the native screen was
|
|
159
|
+
// previously missing — see `completeDeviceFlowSignIn`. `switchSession`
|
|
160
|
+
// is always provided by the context here; the helper requires it.
|
|
161
|
+
if (!switchSession) {
|
|
162
|
+
throw new Error('Session management unavailable');
|
|
163
|
+
}
|
|
164
|
+
const user = await completeDeviceFlowSignIn({
|
|
165
|
+
oxyServices,
|
|
166
|
+
sessionId,
|
|
167
|
+
sessionToken,
|
|
168
|
+
switchSession,
|
|
169
|
+
});
|
|
170
|
+
if (onAuthenticated) {
|
|
171
|
+
onAuthenticated(user);
|
|
154
172
|
}
|
|
155
173
|
} catch (err) {
|
|
156
174
|
debug.error('Error completing auth:', err);
|
|
@@ -189,7 +207,9 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
189
207
|
|
|
190
208
|
if (payload.status === 'authorized' && payload.sessionId) {
|
|
191
209
|
cleanup();
|
|
192
|
-
|
|
210
|
+
// `sessionToken` is this flow's secret credential (in closure) — pass
|
|
211
|
+
// it through so `handleAuthSuccess` can claim the first access token.
|
|
212
|
+
handleAuthSuccess(payload.sessionId, sessionToken);
|
|
193
213
|
} else if (payload.status === 'cancelled') {
|
|
194
214
|
cleanup();
|
|
195
215
|
setError('Authorization was denied.');
|
|
@@ -201,8 +221,11 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
201
221
|
|
|
202
222
|
socket.on('connect_error', (err) => {
|
|
203
223
|
debug.log('Socket connection error, falling back to polling:', (err instanceof Error ? err.message : null));
|
|
204
|
-
//
|
|
224
|
+
// Realtime transport errored — reflect the honest connection state. The
|
|
225
|
+
// poll is already running (started unconditionally in
|
|
226
|
+
// `generateAuthSession`), so `startPolling` here is a no-op backstop.
|
|
205
227
|
socket.disconnect();
|
|
228
|
+
setConnectionType('polling');
|
|
206
229
|
startPolling(sessionToken);
|
|
207
230
|
});
|
|
208
231
|
|
|
@@ -211,9 +234,13 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
211
234
|
});
|
|
212
235
|
}, [oxyServices, handleAuthSuccess]);
|
|
213
236
|
|
|
214
|
-
// Start polling for authorization
|
|
237
|
+
// Start polling for authorization.
|
|
238
|
+
//
|
|
239
|
+
// Idempotent: if a poll interval is already running this is a no-op, so the
|
|
240
|
+
// `connect_error` path (which also calls this) cannot stack a second interval
|
|
241
|
+
// on top of the always-on poll started in `generateAuthSession`.
|
|
215
242
|
const startPolling = useCallback((sessionToken: string) => {
|
|
216
|
-
|
|
243
|
+
if (pollingIntervalRef.current) return;
|
|
217
244
|
|
|
218
245
|
pollingIntervalRef.current = setInterval(async () => {
|
|
219
246
|
if (isProcessingRef.current) return;
|
|
@@ -228,7 +255,9 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
228
255
|
|
|
229
256
|
if (response.authorized && response.sessionId) {
|
|
230
257
|
cleanup();
|
|
231
|
-
|
|
258
|
+
// Pass the original sessionToken (in closure) through; the claim
|
|
259
|
+
// exchange needs it to mint the first access token.
|
|
260
|
+
handleAuthSuccess(response.sessionId, sessionToken);
|
|
232
261
|
} else if (response.status === 'cancelled') {
|
|
233
262
|
cleanup();
|
|
234
263
|
setError('Authorization was denied.');
|
|
@@ -290,14 +319,17 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
290
319
|
setAuthSession({ sessionToken, expiresAt });
|
|
291
320
|
setIsWaiting(true);
|
|
292
321
|
|
|
293
|
-
//
|
|
322
|
+
// Socket is the fast path; the poll is a transport-independent backstop
|
|
323
|
+
// that guarantees completion even if the socket connects but silently
|
|
324
|
+
// never delivers auth_update (RN transport / idle-timeout).
|
|
294
325
|
connectSocket(sessionToken);
|
|
326
|
+
startPolling(sessionToken);
|
|
295
327
|
} catch (err: unknown) {
|
|
296
328
|
setError((err instanceof Error ? err.message : null) || 'Failed to create auth session');
|
|
297
329
|
} finally {
|
|
298
330
|
setIsLoading(false);
|
|
299
331
|
}
|
|
300
|
-
}, [oxyServices, connectSocket, clientId]);
|
|
332
|
+
}, [oxyServices, connectSocket, startPolling, clientId]);
|
|
301
333
|
|
|
302
334
|
// Generate a random session token
|
|
303
335
|
const generateSessionToken = (): string => {
|
|
@@ -379,10 +411,20 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
|
|
|
379
411
|
}
|
|
380
412
|
|
|
381
413
|
if (params.sessionId) {
|
|
414
|
+
// The deep-link return carries only `session_id` — the secret
|
|
415
|
+
// `sessionToken` for this flow lives in component state (generated in
|
|
416
|
+
// `generateAuthSession`). Without it we cannot claim the first access
|
|
417
|
+
// token, so the flow would 401 in `handleAuthSuccess`. If it is somehow
|
|
418
|
+
// unavailable, fall through to the socket/poll path (which carries the
|
|
419
|
+
// token in closure) rather than attempting an unauthenticated claim.
|
|
420
|
+
const flowSessionToken = authSession?.sessionToken;
|
|
421
|
+
if (!flowSessionToken) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
382
424
|
cleanup();
|
|
383
|
-
handleAuthSuccess(params.sessionId);
|
|
425
|
+
handleAuthSuccess(params.sessionId, flowSessionToken);
|
|
384
426
|
}
|
|
385
|
-
}, [cleanup, handleAuthSuccess]);
|
|
427
|
+
}, [authSession, cleanup, handleAuthSuccess]);
|
|
386
428
|
|
|
387
429
|
useEffect(() => {
|
|
388
430
|
if (Platform.OS === 'web') {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*
|
|
4
|
+
* Regression coverage for the NATIVE device-flow sign-in bug:
|
|
5
|
+
*
|
|
6
|
+
* On native, tapping "Sign In with Oxy" opens `OxyAuthScreen`, which creates
|
|
7
|
+
* a device-flow AuthSession and opens auth.oxy.so/authorize. The user signs
|
|
8
|
+
* in successfully, the API authorizes the session and notifies the client via
|
|
9
|
+
* the auth-session socket — but the screen then called `switchSession`
|
|
10
|
+
* DIRECTLY without first claiming the bearer with the secret `sessionToken`.
|
|
11
|
+
* `switchSession` -> `getTokenBySession` (`GET /session/token/:id`) requires a
|
|
12
|
+
* bearer the client did not yet hold, so it 401'd: the session was authorized
|
|
13
|
+
* server-side but the app never became authenticated ("nothing happens").
|
|
14
|
+
*
|
|
15
|
+
* The web `SignInModal` already claimed first; the native screen did not.
|
|
16
|
+
* `completeDeviceFlowSignIn` consolidates the claim->switch sequence so both
|
|
17
|
+
* paths are identical. These tests pin the ORDER (claim before switch) and the
|
|
18
|
+
* fail-fast behaviour.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { User } from '@oxyhq/core';
|
|
22
|
+
import {
|
|
23
|
+
completeDeviceFlowSignIn,
|
|
24
|
+
type DeviceFlowClient,
|
|
25
|
+
} from '../deviceFlowSignIn';
|
|
26
|
+
|
|
27
|
+
const SESSION_ID = 'session-id-123';
|
|
28
|
+
const SESSION_TOKEN = 'a'.repeat(32);
|
|
29
|
+
const USER = { id: 'user-1', username: 'nate', privacySettings: {} } as User;
|
|
30
|
+
|
|
31
|
+
describe('completeDeviceFlowSignIn', () => {
|
|
32
|
+
it('claims the sessionToken BEFORE switching the session', async () => {
|
|
33
|
+
const order: string[] = [];
|
|
34
|
+
|
|
35
|
+
const oxyServices: DeviceFlowClient = {
|
|
36
|
+
claimSessionByToken: jest.fn(async (token: string) => {
|
|
37
|
+
expect(token).toBe(SESSION_TOKEN);
|
|
38
|
+
order.push('claim');
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
const switchSession = jest.fn(async (sessionId: string): Promise<User> => {
|
|
42
|
+
expect(sessionId).toBe(SESSION_ID);
|
|
43
|
+
order.push('switch');
|
|
44
|
+
return USER;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const user = await completeDeviceFlowSignIn({
|
|
48
|
+
oxyServices,
|
|
49
|
+
sessionId: SESSION_ID,
|
|
50
|
+
sessionToken: SESSION_TOKEN,
|
|
51
|
+
switchSession,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(order).toEqual(['claim', 'switch']);
|
|
55
|
+
expect(oxyServices.claimSessionByToken).toHaveBeenCalledWith(SESSION_TOKEN);
|
|
56
|
+
expect(switchSession).toHaveBeenCalledWith(SESSION_ID);
|
|
57
|
+
expect(user).toBe(USER);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does NOT switch the session when the claim fails (the native regression)', async () => {
|
|
61
|
+
const claimError = new Error('claim failed (401)');
|
|
62
|
+
const oxyServices: DeviceFlowClient = {
|
|
63
|
+
claimSessionByToken: jest.fn(async () => {
|
|
64
|
+
throw claimError;
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
const switchSession = jest.fn(async (): Promise<User> => USER);
|
|
68
|
+
|
|
69
|
+
await expect(
|
|
70
|
+
completeDeviceFlowSignIn({
|
|
71
|
+
oxyServices,
|
|
72
|
+
sessionId: SESSION_ID,
|
|
73
|
+
sessionToken: SESSION_TOKEN,
|
|
74
|
+
switchSession,
|
|
75
|
+
}),
|
|
76
|
+
).rejects.toThrow('claim failed (401)');
|
|
77
|
+
|
|
78
|
+
// The bearer was never planted, so we must not attempt the bearer-protected
|
|
79
|
+
// switch — surfacing the failure to the caller instead.
|
|
80
|
+
expect(switchSession).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('propagates a switchSession failure after a successful claim', async () => {
|
|
84
|
+
const switchError = new Error('session invalid');
|
|
85
|
+
const oxyServices: DeviceFlowClient = {
|
|
86
|
+
claimSessionByToken: jest.fn(async () => undefined),
|
|
87
|
+
};
|
|
88
|
+
const switchSession = jest.fn(async (): Promise<User> => {
|
|
89
|
+
throw switchError;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await expect(
|
|
93
|
+
completeDeviceFlowSignIn({
|
|
94
|
+
oxyServices,
|
|
95
|
+
sessionId: SESSION_ID,
|
|
96
|
+
sessionToken: SESSION_TOKEN,
|
|
97
|
+
switchSession,
|
|
98
|
+
}),
|
|
99
|
+
).rejects.toThrow('session invalid');
|
|
100
|
+
|
|
101
|
+
expect(oxyServices.claimSessionByToken).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(switchSession).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, pure orchestration for completing the cross-app device-flow sign-in
|
|
3
|
+
* (the QR-code / "Open Oxy Auth" path used on native and web).
|
|
4
|
+
*
|
|
5
|
+
* THE BUG THIS FIXES (native): once another authenticated device approves the
|
|
6
|
+
* pending AuthSession, the originating client is notified (socket / poll /
|
|
7
|
+
* deep-link) with the authorized `sessionId`. Before any session-management
|
|
8
|
+
* code can use it, the client MUST exchange the secret 128-bit `sessionToken`
|
|
9
|
+
* (held only by this client, generated for THIS flow) for the first access
|
|
10
|
+
* token via `claimSessionByToken` — the device-flow equivalent of OAuth's
|
|
11
|
+
* code-for-token exchange (RFC 8628 §3.4).
|
|
12
|
+
*
|
|
13
|
+
* Skipping the claim leaves the SDK with NO bearer token, so the subsequent
|
|
14
|
+
* `switchSession` -> `getTokenBySession` (`GET /session/token/:id`) call 401s
|
|
15
|
+
* against the C1-hardened API: the session is authorized server-side but the
|
|
16
|
+
* app never becomes authenticated and the UI sits "Waiting for
|
|
17
|
+
* authorization..." forever. The web `SignInModal` already claimed first; the
|
|
18
|
+
* native `OxyAuthScreen` did not. Consolidating the claim→switch sequence here
|
|
19
|
+
* keeps both paths identical and unit-testable, and prevents future drift.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { User } from '@oxyhq/core';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The minimal `OxyServices` surface this orchestration needs. Kept as a
|
|
26
|
+
* structural type (rather than importing the full client) so the helper is
|
|
27
|
+
* trivially unit-testable with a stub and never pulls the RN/Expo runtime into
|
|
28
|
+
* a test bundle.
|
|
29
|
+
*/
|
|
30
|
+
export interface DeviceFlowClient {
|
|
31
|
+
/**
|
|
32
|
+
* Exchange the device-flow `sessionToken` for the first access + refresh
|
|
33
|
+
* token, planting them on the client. Single-use; replay is rejected by the
|
|
34
|
+
* API. No bearer required — the high-entropy `sessionToken` IS the credential.
|
|
35
|
+
*/
|
|
36
|
+
claimSessionByToken: (sessionToken: string) => Promise<unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CompleteDeviceFlowSignInOptions {
|
|
40
|
+
/** The OxyServices client (or any object exposing `claimSessionByToken`). */
|
|
41
|
+
oxyServices: DeviceFlowClient;
|
|
42
|
+
/** The authorized device session id, delivered by the socket / poll / link. */
|
|
43
|
+
sessionId: string;
|
|
44
|
+
/**
|
|
45
|
+
* The secret `sessionToken` generated for THIS flow and registered via
|
|
46
|
+
* `POST /auth/session/create`. Required to claim the first access token.
|
|
47
|
+
*/
|
|
48
|
+
sessionToken: string;
|
|
49
|
+
/**
|
|
50
|
+
* The session-management `switchSession` from `useOxy()`. Hydrates the
|
|
51
|
+
* activated session (validates, fetches the user, persists, updates state).
|
|
52
|
+
* Runs AFTER the bearer is planted so its bearer-protected calls succeed.
|
|
53
|
+
*/
|
|
54
|
+
switchSession: (sessionId: string) => Promise<User>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Complete a device-flow sign-in: claim the first access token with the secret
|
|
59
|
+
* `sessionToken` (planting the bearer), then hydrate the session via
|
|
60
|
+
* `switchSession`. Returns the authenticated user.
|
|
61
|
+
*
|
|
62
|
+
* Throws if either the claim or the switch fails; callers surface a retry UI.
|
|
63
|
+
*/
|
|
64
|
+
export async function completeDeviceFlowSignIn({
|
|
65
|
+
oxyServices,
|
|
66
|
+
sessionId,
|
|
67
|
+
sessionToken,
|
|
68
|
+
switchSession,
|
|
69
|
+
}: CompleteDeviceFlowSignInOptions): Promise<User> {
|
|
70
|
+
// 1) Plant the bearer + refresh tokens. Without this the bearer-protected
|
|
71
|
+
// `getTokenBySession` inside `switchSession` 401s (the native regression).
|
|
72
|
+
await oxyServices.claimSessionByToken(sessionToken);
|
|
73
|
+
|
|
74
|
+
// 2) Bearer is now planted — hydrate the session through the normal path.
|
|
75
|
+
return switchSession(sessionId);
|
|
76
|
+
}
|