@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.
Files changed (38) hide show
  1. package/lib/commonjs/ui/components/SignInModal.js +24 -11
  2. package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
  3. package/lib/commonjs/ui/context/OxyContext.js +5 -1
  4. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  5. package/lib/commonjs/ui/screens/OxyAuthScreen.js +66 -25
  6. package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
  7. package/lib/commonjs/utils/deviceFlowSignIn.js +55 -0
  8. package/lib/commonjs/utils/deviceFlowSignIn.js.map +1 -0
  9. package/lib/module/ui/components/SignInModal.js +24 -11
  10. package/lib/module/ui/components/SignInModal.js.map +1 -1
  11. package/lib/module/ui/context/OxyContext.js +5 -1
  12. package/lib/module/ui/context/OxyContext.js.map +1 -1
  13. package/lib/module/ui/screens/OxyAuthScreen.js +66 -25
  14. package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
  15. package/lib/module/utils/deviceFlowSignIn.js +51 -0
  16. package/lib/module/utils/deviceFlowSignIn.js.map +1 -0
  17. package/lib/typescript/commonjs/ui/components/SignInModal.d.ts.map +1 -1
  18. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +1 -1
  19. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  20. package/lib/typescript/commonjs/ui/hooks/mutations/useServicesMutations.d.ts +1 -1
  21. package/lib/typescript/commonjs/ui/hooks/mutations/useServicesMutations.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts +61 -0
  24. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts.map +1 -0
  25. package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
  26. package/lib/typescript/module/ui/context/OxyContext.d.ts +1 -1
  27. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  28. package/lib/typescript/module/ui/hooks/mutations/useServicesMutations.d.ts +1 -1
  29. package/lib/typescript/module/ui/hooks/mutations/useServicesMutations.d.ts.map +1 -1
  30. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  31. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts +61 -0
  32. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts.map +1 -0
  33. package/package.json +1 -1
  34. package/src/ui/components/SignInModal.tsx +25 -11
  35. package/src/ui/context/OxyContext.tsx +8 -4
  36. package/src/ui/screens/OxyAuthScreen.tsx +67 -25
  37. package/src/utils/__tests__/deviceFlowSignIn.test.ts +104 -0
  38. 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
- // Plant the bearer + refresh tokens for this newly-authorized
157
- // session. Single-use replay attempts on this sessionToken
158
- // are rejected by the API.
159
- await oxyServices.claimSessionByToken(sessionToken);
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 (fallback)
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<void>;
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<void> => {
1559
- await switchSession(sessionId);
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<void>(),
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, signIn, switchSession, clientId } = useOxy();
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
- const handleAuthSuccess = useCallback(async (sessionId: string) => {
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
- // Switch to the new session (this will get token, user data, and update state)
142
- if (switchSession) {
143
- const user = await switchSession(sessionId);
144
- if (onAuthenticated) {
145
- onAuthenticated(user);
146
- }
147
- } else {
148
- // Fallback if switchSession not available (shouldn't happen, but for safety)
149
- await oxyServices.getTokenBySession(sessionId);
150
- const user = await oxyServices.getUserBySession(sessionId);
151
- if (onAuthenticated) {
152
- onAuthenticated(user);
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
- handleAuthSuccess(payload.sessionId);
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
- // Fall back to polling if socket fails
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 (fallback)
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
- setConnectionType('polling');
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
- handleAuthSuccess(response.sessionId);
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
- // Try socket first, will fall back to polling if needed
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
+ }