@oxyhq/services 9.0.0 → 10.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.
Files changed (77) hide show
  1. package/lib/commonjs/ui/components/OxyProvider.js +2 -2
  2. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  3. package/lib/commonjs/ui/components/SignInModal.js +26 -12
  4. package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
  5. package/lib/commonjs/ui/context/OxyContext.js +35 -19
  6. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  7. package/lib/commonjs/ui/hooks/useWebSSO.js +7 -8
  8. package/lib/commonjs/ui/hooks/useWebSSO.js.map +1 -1
  9. package/lib/commonjs/ui/screens/OxyAuthScreen.js +65 -23
  10. package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
  11. package/lib/commonjs/utils/deviceFlowSignIn.js +55 -0
  12. package/lib/commonjs/utils/deviceFlowSignIn.js.map +1 -0
  13. package/lib/commonjs/utils/silentGuardKey.js +54 -0
  14. package/lib/commonjs/utils/silentGuardKey.js.map +1 -0
  15. package/lib/module/ui/components/OxyProvider.js +2 -2
  16. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  17. package/lib/module/ui/components/SignInModal.js +26 -12
  18. package/lib/module/ui/components/SignInModal.js.map +1 -1
  19. package/lib/module/ui/context/OxyContext.js +35 -19
  20. package/lib/module/ui/context/OxyContext.js.map +1 -1
  21. package/lib/module/ui/hooks/useWebSSO.js +7 -8
  22. package/lib/module/ui/hooks/useWebSSO.js.map +1 -1
  23. package/lib/module/ui/screens/OxyAuthScreen.js +65 -23
  24. package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
  25. package/lib/module/utils/deviceFlowSignIn.js +51 -0
  26. package/lib/module/utils/deviceFlowSignIn.js.map +1 -0
  27. package/lib/module/utils/silentGuardKey.js +49 -0
  28. package/lib/module/utils/silentGuardKey.js.map +1 -0
  29. package/lib/typescript/commonjs/ui/components/SignInModal.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +13 -9
  31. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/ui/hooks/mutations/useServicesMutations.d.ts +1 -1
  34. package/lib/typescript/commonjs/ui/hooks/mutations/useServicesMutations.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/types/navigation.d.ts +8 -6
  38. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts +61 -0
  40. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/utils/silentGuardKey.d.ts +31 -0
  42. package/lib/typescript/commonjs/utils/silentGuardKey.d.ts.map +1 -0
  43. package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
  44. package/lib/typescript/module/ui/context/OxyContext.d.ts +13 -9
  45. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  46. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  47. package/lib/typescript/module/ui/hooks/mutations/useServicesMutations.d.ts +1 -1
  48. package/lib/typescript/module/ui/hooks/mutations/useServicesMutations.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/hooks/useWebSSO.d.ts.map +1 -1
  50. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/types/navigation.d.ts +8 -6
  52. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  53. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts +61 -0
  54. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts.map +1 -0
  55. package/lib/typescript/module/utils/silentGuardKey.d.ts +31 -0
  56. package/lib/typescript/module/utils/silentGuardKey.d.ts.map +1 -0
  57. package/package.json +2 -2
  58. package/src/ui/components/OxyProvider.tsx +2 -2
  59. package/src/ui/components/SignInModal.tsx +26 -12
  60. package/src/ui/context/OxyContext.tsx +50 -33
  61. package/src/ui/hooks/useWebSSO.ts +7 -8
  62. package/src/ui/screens/OxyAuthScreen.tsx +65 -22
  63. package/src/ui/types/navigation.ts +8 -6
  64. package/src/utils/__tests__/deviceFlowSignIn.test.ts +104 -0
  65. package/src/utils/__tests__/silentGuardKey.test.ts +82 -0
  66. package/src/utils/deviceFlowSignIn.ts +76 -0
  67. package/src/utils/silentGuardKey.ts +46 -0
  68. package/lib/commonjs/ui/utils/appName.js +0 -62
  69. package/lib/commonjs/ui/utils/appName.js.map +0 -1
  70. package/lib/module/ui/utils/appName.js +0 -59
  71. package/lib/module/ui/utils/appName.js.map +0 -1
  72. package/lib/typescript/commonjs/ui/utils/appName.d.ts +0 -22
  73. package/lib/typescript/commonjs/ui/utils/appName.d.ts.map +0 -1
  74. package/lib/typescript/module/ui/utils/appName.d.ts +0 -22
  75. package/lib/typescript/module/ui/utils/appName.d.ts.map +0 -1
  76. package/src/ui/utils/__tests__/appName.test.ts +0 -52
  77. package/src/ui/utils/appName.ts +0 -62
@@ -1 +1 @@
1
- {"version":3,"file":"navigation.d.ts","sourceRoot":"","sources":["../../../../../src/ui/types/navigation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAKtD,YAAY,EAAE,SAAS,EAAE,CAAC;AAE1B,MAAM,WAAW,cAAc;IAC3B,SAAS,EAAE,MAAM,OAAO,CAAC;IACzB,MAAM,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAE5B,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACxE,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,eAAe,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAG9C,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;IAGlC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB,CAAC,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IACrD,YAAY,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAGjE,aAAa,CAAC,EAAE,SAAS,CAAC;IAG1B,QAAQ,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAGnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,OAAO,CAAC;IAMtB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC7B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,iBAAiB,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IAC5C,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,WAAW,CAAC;CAC7B"}
1
+ {"version":3,"file":"navigation.d.ts","sourceRoot":"","sources":["../../../../../src/ui/types/navigation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAKtD,YAAY,EAAE,SAAS,EAAE,CAAC;AAE1B,MAAM,WAAW,cAAc;IAC3B,SAAS,EAAE,MAAM,OAAO,CAAC;IACzB,MAAM,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAE5B,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACxE,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,eAAe,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAG9C,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;IAGlC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB,CAAC,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IACrD,YAAY,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAGjE,aAAa,CAAC,EAAE,SAAS,CAAC;IAG1B,QAAQ,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAGnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,OAAO,CAAC;IAMtB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC7B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,iBAAiB,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IAC5C,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,WAAW,CAAC;CAC7B"}
@@ -0,0 +1,61 @@
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
+ import type { User } from '@oxyhq/core';
22
+ /**
23
+ * The minimal `OxyServices` surface this orchestration needs. Kept as a
24
+ * structural type (rather than importing the full client) so the helper is
25
+ * trivially unit-testable with a stub and never pulls the RN/Expo runtime into
26
+ * a test bundle.
27
+ */
28
+ export interface DeviceFlowClient {
29
+ /**
30
+ * Exchange the device-flow `sessionToken` for the first access + refresh
31
+ * token, planting them on the client. Single-use; replay is rejected by the
32
+ * API. No bearer required — the high-entropy `sessionToken` IS the credential.
33
+ */
34
+ claimSessionByToken: (sessionToken: string) => Promise<unknown>;
35
+ }
36
+ export interface CompleteDeviceFlowSignInOptions {
37
+ /** The OxyServices client (or any object exposing `claimSessionByToken`). */
38
+ oxyServices: DeviceFlowClient;
39
+ /** The authorized device session id, delivered by the socket / poll / link. */
40
+ sessionId: string;
41
+ /**
42
+ * The secret `sessionToken` generated for THIS flow and registered via
43
+ * `POST /auth/session/create`. Required to claim the first access token.
44
+ */
45
+ sessionToken: string;
46
+ /**
47
+ * The session-management `switchSession` from `useOxy()`. Hydrates the
48
+ * activated session (validates, fetches the user, persists, updates state).
49
+ * Runs AFTER the bearer is planted so its bearer-protected calls succeed.
50
+ */
51
+ switchSession: (sessionId: string) => Promise<User>;
52
+ }
53
+ /**
54
+ * Complete a device-flow sign-in: claim the first access token with the secret
55
+ * `sessionToken` (planting the bearer), then hydrate the session via
56
+ * `switchSession`. Returns the authenticated user.
57
+ *
58
+ * Throws if either the claim or the switch fails; callers surface a retry UI.
59
+ */
60
+ export declare function completeDeviceFlowSignIn({ oxyServices, sessionId, sessionToken, switchSession, }: CompleteDeviceFlowSignInOptions): Promise<User>;
61
+ //# sourceMappingURL=deviceFlowSignIn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deviceFlowSignIn.d.ts","sourceRoot":"","sources":["../../../../src/utils/deviceFlowSignIn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAExC;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,mBAAmB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACjE;AAED,MAAM,WAAW,+BAA+B;IAC9C,6EAA6E;IAC7E,WAAW,EAAE,gBAAgB,CAAC;IAC9B,+EAA+E;IAC/E,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrD;AAED;;;;;;GAMG;AACH,wBAAsB,wBAAwB,CAAC,EAC7C,WAAW,EACX,SAAS,EACT,YAAY,EACZ,aAAa,GACd,EAAE,+BAA+B,GAAG,OAAO,CAAC,IAAI,CAAC,CAOjD"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared, pure helpers for building the `origin|baseURL` signature used as the
3
+ * module-level run-once guard key for cold-boot silent-SSO probes
4
+ * (`silentColdBootKey` in `OxyContext`, `ssoSignature` in `useWebSSO`).
5
+ *
6
+ * NATIVE SAFETY (the bug this fixes): React Native aliases a global `window`
7
+ * (it points at the JS global object), so `typeof window !== 'undefined'` is
8
+ * `true` on native — but `window.location` is `undefined`. Reading
9
+ * `window.location.origin` after only a `typeof window` check therefore throws
10
+ * `TypeError: Cannot read property 'origin' of undefined` on native. Because
11
+ * the key is built UNCONDITIONALLY at the top of the cold-boot path (before its
12
+ * try/catch), that throw escaped session restore entirely and broke
13
+ * cross-session restore on native. Both prior copies of the guard had the same
14
+ * insufficient `typeof window` check and were prone to drift, so the read is
15
+ * consolidated here behind a guard that also verifies `window.location`.
16
+ */
17
+ /**
18
+ * Read `window.location.origin` safely on every platform.
19
+ *
20
+ * Returns the browser origin on web, and the sentinel `'no-origin'` anywhere
21
+ * `window.location` is absent (React Native, SSR/Node). Never throws.
22
+ */
23
+ export declare function safeWindowOrigin(): string;
24
+ /**
25
+ * Build the stable `origin|baseURL` signature for the silent-SSO run-once
26
+ * guard. Two providers pointed at the same API from the same origin share one
27
+ * attempt. `getBaseURL` is invoked defensively (it may be absent or throw on a
28
+ * partially-initialised client); any failure degrades to an empty baseURL.
29
+ */
30
+ export declare function buildSilentGuardKey(getBaseURL?: () => string | undefined): string;
31
+ //# sourceMappingURL=silentGuardKey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"silentGuardKey.d.ts","sourceRoot":"","sources":["../../../../src/utils/silentGuardKey.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH;;;;;GAKG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAKzC;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,GAAG,MAAM,CASjF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/services",
3
- "version": "9.0.0",
3
+ "version": "10.1.0",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -160,7 +160,7 @@
160
160
  "peerDependencies": {
161
161
  "@expo/vector-icons": "^15.0.3",
162
162
  "@oxyhq/bloom": ">=0.5.0",
163
- "@oxyhq/core": "^3.0.0",
163
+ "@oxyhq/core": "^3.2.0",
164
164
  "@react-native-community/netinfo": "^11.4.1",
165
165
  "@tanstack/query-async-storage-persister": "^5.100",
166
166
  "@tanstack/query-sync-storage-persister": "^5.100",
@@ -103,7 +103,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
103
103
  children,
104
104
  onAuthStateChange,
105
105
  storageKeyPrefix,
106
- appName,
106
+ clientId,
107
107
  baseURL,
108
108
  authWebUrl,
109
109
  authRedirectUri,
@@ -297,7 +297,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
297
297
  authWebUrl={authWebUrl}
298
298
  authRedirectUri={authRedirectUri}
299
299
  storageKeyPrefix={storageKeyPrefix}
300
- appName={appName}
300
+ clientId={clientId}
301
301
  onAuthStateChange={onAuthStateChange as OxyContextProviderProps['onAuthStateChange']}
302
302
  >
303
303
  {children}
@@ -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
 
@@ -93,7 +94,7 @@ const SignInModal: React.FC = () => {
93
94
 
94
95
  const insets = useSafeAreaInsets();
95
96
  const theme = useTheme();
96
- const { oxyServices, switchSession, appName } = useOxy();
97
+ const { oxyServices, switchSession, clientId } = useOxy();
97
98
 
98
99
  const socketRef = useRef<Socket | null>(null);
99
100
  const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -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) {
@@ -266,6 +269,17 @@ const SignInModal: React.FC = () => {
266
269
  setError(null);
267
270
  isProcessingRef.current = false;
268
271
 
272
+ // The cross-app device sign-in flow identifies the requesting app by its
273
+ // real registered OAuth client id (ApplicationCredential publicKey).
274
+ // Without it the API cannot resolve the consent identity, so we fail
275
+ // fast with a clear configuration error rather than creating a session
276
+ // the server would reject.
277
+ if (!clientId) {
278
+ setError('This app is not configured for sign-in (missing clientId).');
279
+ setIsLoading(false);
280
+ return;
281
+ }
282
+
269
283
  try {
270
284
  const sessionToken = generateSessionToken();
271
285
  const expiresAt = Date.now() + AUTH_SESSION_EXPIRY_MS;
@@ -273,7 +287,7 @@ const SignInModal: React.FC = () => {
273
287
  await oxyServices.makeRequest('POST', '/auth/session/create', {
274
288
  sessionToken,
275
289
  expiresAt,
276
- appId: appName,
290
+ clientId,
277
291
  }, { cache: false });
278
292
 
279
293
  setAuthSession({ sessionToken, expiresAt });
@@ -284,7 +298,7 @@ const SignInModal: React.FC = () => {
284
298
  } finally {
285
299
  setIsLoading(false);
286
300
  }
287
- }, [oxyServices, connectSocket, appName]);
301
+ }, [oxyServices, connectSocket, clientId]);
288
302
 
289
303
  // Generate a cryptographically random session token.
290
304
  // 16 random bytes -> 32 hex chars (128 bits of entropy) — unguessable.
@@ -42,7 +42,6 @@ import { useDeviceManagement } from '../hooks/useDeviceManagement';
42
42
  import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
43
43
  import { isInvalidSessionError, isTimeoutOrNetworkError } from '../utils/errorHandlers';
44
44
  import { readActiveAuthuser, writeActiveAuthuser } from '../utils/activeAuthuser';
45
- import { resolveAppDisplayName } from '../utils/appName';
46
45
  import type { RouteName } from '../navigation/routes';
47
46
  import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
48
47
  import { useQueryClient } from '@tanstack/react-query';
@@ -51,6 +50,7 @@ import { useAvatarPicker } from '../hooks/useAvatarPicker';
51
50
  import { useAccountStore } from '../stores/accountStore';
52
51
  import { logger as loggerUtil } from '@oxyhq/core';
53
52
  import { useWebSSO, isWebBrowser } from '../hooks/useWebSSO';
53
+ import { buildSilentGuardKey } from '../../utils/silentGuardKey';
54
54
 
55
55
  export interface OxyContextState {
56
56
  user: User | null;
@@ -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>;
@@ -116,12 +116,16 @@ export interface OxyContextState {
116
116
  clearAllAccountData: () => Promise<void>;
117
117
  storageKeyPrefix: string;
118
118
  /**
119
- * Resolved human-readable app display name surfaced on the central Oxy
120
- * sign-in / consent experience (e.g. "Mention wants to access your Oxy
121
- * account"). Always non-empty derived from the `appName` prop, then
122
- * `storageKeyPrefix`, then `document.title` (web), then the platform.
119
+ * The app's Oxy OAuth client id / ApplicationCredential publicKey, as
120
+ * supplied via the `clientId` prop. Required for the cross-app device
121
+ * sign-in flow: the sign-in components send it to
122
+ * `POST /auth/session/create` so the API can identify the requesting app by
123
+ * its real registered client id (the consent identity is then resolved
124
+ * server-side and shown by the central auth web). `null` when the consuming
125
+ * app did not configure a client id — the device sign-in flow surfaces a
126
+ * configuration error in that case.
123
127
  */
124
- appName: string;
128
+ clientId: string | null;
125
129
  oxyServices: OxyServices;
126
130
  useFollow?: UseFollowHook;
127
131
  showBottomSheet?: (screenOrConfig: RouteName | { screen: RouteName; props?: Record<string, unknown> }) => void;
@@ -149,10 +153,10 @@ export interface OxyContextProviderProps {
149
153
  authRedirectUri?: string;
150
154
  storageKeyPrefix?: string;
151
155
  /**
152
- * Human-readable name of the consuming app shown on the central Oxy
153
- * sign-in / consent experience. See {@link OxyContextState.appName}.
156
+ * The app's Oxy OAuth client id / ApplicationCredential publicKey; required
157
+ * for the cross-app device sign-in flow. See {@link OxyContextState.clientId}.
154
158
  */
155
- appName?: string;
159
+ clientId?: string;
156
160
  onAuthStateChange?: (user: User | null) => void;
157
161
  onError?: (error: ApiError) => void;
158
162
  }
@@ -181,14 +185,15 @@ const servicesSilentAttempted = new Set<string>();
181
185
  * Build the `origin|baseURL` signature used as the silent-cold-boot guard key.
182
186
  */
183
187
  function silentColdBootKey(oxyServices: OxyServices): string {
184
- const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
185
- let baseURL = '';
186
- try {
187
- baseURL = oxyServices.getBaseURL?.() ?? '';
188
- } catch {
189
- baseURL = '';
190
- }
191
- return `${origin}|${baseURL}`;
188
+ // `buildSilentGuardKey` reads `window.location.origin` behind a guard that
189
+ // also verifies `window.location` exists. This is critical: it runs
190
+ // UNCONDITIONALLY at the top of `restoreSessionsFromStorage` (before the
191
+ // cold-boot try/catch) on EVERY platform, and React Native aliases a global
192
+ // `window` with NO `window.location`. Without that guard the read threw
193
+ // `Cannot read property 'origin' of undefined` on native, escaping the
194
+ // restore path so `markAuthResolved` never ran and stored-session restore was
195
+ // never reached.
196
+ return buildSilentGuardKey(() => oxyServices.getBaseURL?.());
192
197
  }
193
198
 
194
199
  /**
@@ -258,7 +263,12 @@ const COLD_BOOT_OVERALL_DEADLINE = 20000;
258
263
  * off-browser.
259
264
  */
260
265
  function isSameSiteIdP(idpOrigin: string): boolean {
261
- if (typeof window === 'undefined') return false;
266
+ // Native defines a global `window` but no `window.location`; guard the
267
+ // latter so reading `.hostname` can never throw off-browser. (Only reachable
268
+ // from the web-only visibility check, but kept robust for parity.)
269
+ if (typeof window === 'undefined' || typeof window.location === 'undefined') {
270
+ return false;
271
+ }
262
272
  let idpHostname: string;
263
273
  try {
264
274
  idpHostname = new URL(idpOrigin).hostname;
@@ -314,7 +324,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
314
324
  authWebUrl,
315
325
  authRedirectUri,
316
326
  storageKeyPrefix = 'oxy_session',
317
- appName: appNameProp,
327
+ clientId: clientIdProp,
318
328
  onAuthStateChange,
319
329
  onError,
320
330
  }) => {
@@ -424,13 +434,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
424
434
 
425
435
  const storageKeys = useMemo(() => getStorageKeys(storageKeyPrefix), [storageKeyPrefix]);
426
436
 
427
- // Human-readable app display name for the central sign-in / consent UI.
428
- // Derived once from the consumer config; never "web" unless the app supplies
429
- // no name, no custom prefix, and no document title.
430
- const appName = useMemo(
431
- () => resolveAppDisplayName(appNameProp, storageKeyPrefix),
432
- [appNameProp, storageKeyPrefix],
433
- );
437
+ // The app's Oxy OAuth client id surfaced on the context so the cross-app
438
+ // device sign-in components (SignInModal / OxyAuthScreen) can identify the
439
+ // requesting app to `POST /auth/session/create`. Normalized to a trimmed
440
+ // non-empty string, or `null` when the consumer did not configure one — the
441
+ // sign-in components surface a clear configuration error in that case rather
442
+ // than falling back to any display string.
443
+ const clientId = useMemo(() => {
444
+ const trimmed = clientIdProp?.trim();
445
+ return trimmed ? trimmed : null;
446
+ }, [clientIdProp]);
434
447
 
435
448
  // Storage initialization.
436
449
  //
@@ -1542,8 +1555,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1542
1555
  });
1543
1556
 
1544
1557
  const switchSessionForContext = useCallback(
1545
- async (sessionId: string): Promise<void> => {
1546
- 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);
1547
1564
  },
1548
1565
  [switchSession],
1549
1566
  );
@@ -1666,7 +1683,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1666
1683
  clearSessionState,
1667
1684
  clearAllAccountData,
1668
1685
  storageKeyPrefix,
1669
- appName,
1686
+ clientId,
1670
1687
  oxyServices,
1671
1688
  useFollow: useFollowHook,
1672
1689
  showBottomSheet: showBottomSheetForContext,
@@ -1695,7 +1712,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1695
1712
  logoutAllDeviceSessions,
1696
1713
  oxyServices,
1697
1714
  storageKeyPrefix,
1698
- appName,
1715
+ clientId,
1699
1716
  refreshSessionsWithUser,
1700
1717
  sessions,
1701
1718
  setLanguage,
@@ -1763,7 +1780,7 @@ const LOADING_STATE: OxyContextState = {
1763
1780
  handlePopupSession: () => rejectMissingProvider<void>(),
1764
1781
  logout: () => rejectMissingProvider<void>(),
1765
1782
  logoutAll: () => rejectMissingProvider<void>(),
1766
- switchSession: () => rejectMissingProvider<void>(),
1783
+ switchSession: () => rejectMissingProvider<User>(),
1767
1784
  removeSession: () => rejectMissingProvider<void>(),
1768
1785
  refreshSessions: () => rejectMissingProvider<void>(),
1769
1786
  setLanguage: () => rejectMissingProvider<void>(),
@@ -1773,7 +1790,7 @@ const LOADING_STATE: OxyContextState = {
1773
1790
  clearSessionState: () => rejectMissingProvider<void>(),
1774
1791
  clearAllAccountData: () => rejectMissingProvider<void>(),
1775
1792
  storageKeyPrefix: 'oxy_session',
1776
- appName: resolveAppDisplayName(undefined, undefined),
1793
+ clientId: null,
1777
1794
  oxyServices: LOADING_STATE_OXY_SERVICES,
1778
1795
  openAvatarPicker: () => {},
1779
1796
  actingAs: null,
@@ -18,6 +18,7 @@
18
18
  import { useEffect, useRef, useCallback } from 'react';
19
19
  import type { OxyServices } from '@oxyhq/core';
20
20
  import type { SessionLoginResponse } from '@oxyhq/core';
21
+ import { buildSilentGuardKey } from '../../utils/silentGuardKey';
21
22
 
22
23
  interface UseWebSSOOptions {
23
24
  oxyServices: OxyServices;
@@ -58,14 +59,12 @@ const silentSSOAttempted = new Set<string>();
58
59
  * pointed at the same API from the same origin share one attempt.
59
60
  */
60
61
  function ssoSignature(oxyServices: OxyServices): string {
61
- const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
62
- let baseURL = '';
63
- try {
64
- baseURL = oxyServices.getBaseURL?.() ?? '';
65
- } catch {
66
- baseURL = '';
67
- }
68
- return `${origin}|${baseURL}`;
62
+ // Shared with `OxyContext.silentColdBootKey`. `buildSilentGuardKey` reads
63
+ // `window.location.origin` behind a guard that also verifies
64
+ // `window.location` exists — React Native aliases a global `window` with NO
65
+ // `window.location`, so a `typeof window`-only check would throw
66
+ // `Cannot read property 'origin' of undefined` off-browser.
67
+ return buildSilentGuardKey(() => oxyServices.getBaseURL?.());
69
68
  }
70
69
 
71
70
  /**
@@ -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, appName } = 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.');
@@ -228,7 +248,9 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
228
248
 
229
249
  if (response.authorized && response.sessionId) {
230
250
  cleanup();
231
- handleAuthSuccess(response.sessionId);
251
+ // Pass the original sessionToken (in closure) through; the claim
252
+ // exchange needs it to mint the first access token.
253
+ handleAuthSuccess(response.sessionId, sessionToken);
232
254
  } else if (response.status === 'cancelled') {
233
255
  cleanup();
234
256
  setError('Authorization was denied.');
@@ -264,6 +286,17 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
264
286
  setError(null);
265
287
  isProcessingRef.current = false;
266
288
 
289
+ // The cross-app device sign-in flow identifies the requesting app by its
290
+ // real registered OAuth client id (ApplicationCredential publicKey).
291
+ // Without it the API cannot resolve the consent identity, so we fail fast
292
+ // with a clear configuration error rather than creating a session the
293
+ // server would reject.
294
+ if (!clientId) {
295
+ setError('This app is not configured for sign-in (missing clientId).');
296
+ setIsLoading(false);
297
+ return;
298
+ }
299
+
267
300
  try {
268
301
  // Generate a unique session token for this auth request
269
302
  const sessionToken = generateSessionToken();
@@ -273,7 +306,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
273
306
  await oxyServices.makeRequest('POST', '/auth/session/create', {
274
307
  sessionToken,
275
308
  expiresAt,
276
- appId: appName,
309
+ clientId,
277
310
  }, { cache: false });
278
311
 
279
312
  setAuthSession({ sessionToken, expiresAt });
@@ -286,7 +319,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
286
319
  } finally {
287
320
  setIsLoading(false);
288
321
  }
289
- }, [oxyServices, connectSocket, appName]);
322
+ }, [oxyServices, connectSocket, clientId]);
290
323
 
291
324
  // Generate a random session token
292
325
  const generateSessionToken = (): string => {
@@ -368,10 +401,20 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
368
401
  }
369
402
 
370
403
  if (params.sessionId) {
404
+ // The deep-link return carries only `session_id` — the secret
405
+ // `sessionToken` for this flow lives in component state (generated in
406
+ // `generateAuthSession`). Without it we cannot claim the first access
407
+ // token, so the flow would 401 in `handleAuthSuccess`. If it is somehow
408
+ // unavailable, fall through to the socket/poll path (which carries the
409
+ // token in closure) rather than attempting an unauthenticated claim.
410
+ const flowSessionToken = authSession?.sessionToken;
411
+ if (!flowSessionToken) {
412
+ return;
413
+ }
371
414
  cleanup();
372
- handleAuthSuccess(params.sessionId);
415
+ handleAuthSuccess(params.sessionId, flowSessionToken);
373
416
  }
374
- }, [cleanup, handleAuthSuccess]);
417
+ }, [authSession, cleanup, handleAuthSuccess]);
375
418
 
376
419
  useEffect(() => {
377
420
  if (Platform.OS === 'web') {
@@ -53,13 +53,15 @@ export interface OxyProviderProps {
53
53
  onAuthStateChange?: (user: unknown) => void;
54
54
  storageKeyPrefix?: string;
55
55
  /**
56
- * Human-readable name of the consuming app (e.g. "Mention", "Homiio").
57
- * Surfaced on the central Oxy sign-in / consent experience as
58
- * "{appName} wants to access your Oxy account". When omitted, the SDK
59
- * derives a name from `storageKeyPrefix`, then `document.title` (web),
60
- * falling back to the platform. Set this to guarantee correct branding.
56
+ * The app's Oxy OAuth client id / ApplicationCredential publicKey.
57
+ * Required for the cross-app device sign-in flow: the QR / popup
58
+ * sign-in registers a device-flow session via `POST /auth/session/create`,
59
+ * which now identifies the requesting app by this real registered
60
+ * client id. The central Oxy auth experience resolves and renders the
61
+ * consent identity from it server-side. Without it the device sign-in
62
+ * flow cannot start.
61
63
  */
62
- appName?: string;
64
+ clientId?: string;
63
65
  baseURL?: string;
64
66
  authWebUrl?: string;
65
67
  authRedirectUri?: string;