@oxyhq/services 12.1.0 → 12.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 (56) hide show
  1. package/lib/commonjs/index.js +17 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/SignInModal.js +9 -2
  4. package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
  5. package/lib/commonjs/ui/context/OxyContext.js +15 -8
  6. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  7. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +8 -0
  8. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  9. package/lib/commonjs/ui/context/inSessionTokenRefresh.js +66 -90
  10. package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +1 -1
  11. package/lib/commonjs/ui/screens/OxyAuthScreen.js +10 -2
  12. package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
  13. package/lib/commonjs/utils/crossApex.js +74 -0
  14. package/lib/commonjs/utils/crossApex.js.map +1 -0
  15. package/lib/module/index.js +5 -0
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/ui/components/SignInModal.js +9 -2
  18. package/lib/module/ui/components/SignInModal.js.map +1 -1
  19. package/lib/module/ui/context/OxyContext.js +15 -8
  20. package/lib/module/ui/context/OxyContext.js.map +1 -1
  21. package/lib/module/ui/context/hooks/useAuthOperations.js +8 -0
  22. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  23. package/lib/module/ui/context/inSessionTokenRefresh.js +67 -91
  24. package/lib/module/ui/context/inSessionTokenRefresh.js.map +1 -1
  25. package/lib/module/ui/screens/OxyAuthScreen.js +10 -2
  26. package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
  27. package/lib/module/utils/crossApex.js +69 -0
  28. package/lib/module/utils/crossApex.js.map +1 -0
  29. package/lib/typescript/commonjs/index.d.ts +1 -0
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/components/SignInModal.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +27 -31
  35. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/utils/crossApex.d.ts +55 -0
  38. package/lib/typescript/commonjs/utils/crossApex.d.ts.map +1 -0
  39. package/lib/typescript/module/index.d.ts +1 -0
  40. package/lib/typescript/module/index.d.ts.map +1 -1
  41. package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
  42. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  43. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  44. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +27 -31
  45. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
  46. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  47. package/lib/typescript/module/utils/crossApex.d.ts +55 -0
  48. package/lib/typescript/module/utils/crossApex.d.ts.map +1 -0
  49. package/package.json +1 -1
  50. package/src/index.ts +4 -0
  51. package/src/ui/components/SignInModal.tsx +18 -6
  52. package/src/ui/context/OxyContext.tsx +15 -8
  53. package/src/ui/context/hooks/useAuthOperations.ts +8 -0
  54. package/src/ui/context/inSessionTokenRefresh.ts +75 -90
  55. package/src/ui/screens/OxyAuthScreen.tsx +20 -6
  56. package/src/utils/crossApex.ts +75 -0
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Cross-apex web detection for sign-in durability gating.
3
+ *
4
+ * On a web Relying Party whose registrable apex differs from the central Oxy
5
+ * Identity Provider apex (`oxy.so`), the ONLY sign-in completion that survives a
6
+ * page reload is the interactive "Continue with Oxy" IdP flow, because it is the
7
+ * only one that establishes a first-party `fedcm_session` cookie at
8
+ * `auth.<apex>` — the anchor the cross-domain cold-boot restore reads on reload.
9
+ *
10
+ * The other sign-in completions the SDK can drive do NOT plant a `fedcm_session`:
11
+ * - password / public-key sign-in mints a bearer directly against the Oxy API
12
+ * (its refresh cookie is host-scoped to `api.oxy.so` and `SameSite=Lax`, so
13
+ * it is unreachable cross-site from the RP), and
14
+ * - the Commons-app device-flow handoff (cross-device QR / same-device
15
+ * deep-link) is approved OUTSIDE the browser, so no IdP browser session is
16
+ * established.
17
+ * On a cross-apex RP each of these leaves the user signed in for the current
18
+ * page only — a reload logs them out. See OxyHQServices AGENTS.md
19
+ * "Auth / Session Contract".
20
+ *
21
+ * Same-apex `*.oxy.so` apps (e.g. `accounts.oxy.so`) are first-party with
22
+ * `api.oxy.so`: their refresh cookie rides same-site requests so reload restore
23
+ * works without `fedcm_session`, and they are therefore NOT gated.
24
+ *
25
+ * Returns `false` off-browser (React Native has no `window.location`) and for
26
+ * hosts without a registrable apex (localhost / raw IP / single-label dev
27
+ * hosts), so native and local development keep every sign-in method.
28
+ */
29
+ /**
30
+ * Whether the given host is a web RP on a registrable apex other than the
31
+ * central Oxy IdP apex (`oxy.so`). See the module doc for why this gates the
32
+ * non-durable sign-in paths.
33
+ *
34
+ * @param hostname - The host to classify. Defaults to the current
35
+ * `window.location.hostname`; resolves to `undefined` off-browser (React
36
+ * Native / SSR), which yields `false`. The explicit parameter mirrors
37
+ * `autoDetectAuthWebUrl(location?)` and keeps the predicate unit-testable
38
+ * without manipulating the global `window.location`.
39
+ */
40
+ export declare function isCrossApexWeb(hostname?: string | undefined): boolean;
41
+ /**
42
+ * Thrown when an app attempts a direct (non-IdP) sign-in — password or
43
+ * public-key — on a cross-apex web RP, where such a sign-in would not survive a
44
+ * page reload because no `fedcm_session` is established (see {@link isCrossApexWeb}).
45
+ *
46
+ * Apps on a cross-apex apex must sign in through the interactive
47
+ * "Continue with Oxy" IdP flow (`OxySignInButton` / `showSignInModal()`), which
48
+ * plants the durable session.
49
+ */
50
+ export declare class CrossApexDirectSignInError extends Error {
51
+ readonly name = "CrossApexDirectSignInError";
52
+ readonly code = "CROSS_APEX_DIRECT_SIGN_IN_UNSUPPORTED";
53
+ constructor();
54
+ }
55
+ //# sourceMappingURL=crossApex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crossApex.d.ts","sourceRoot":"","sources":["../../../../src/utils/crossApex.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,GAAE,MAAM,GAAG,SAEN,GACZ,OAAO,CAMT;AAED;;;;;;;;GAQG;AACH,qBAAa,0BAA2B,SAAQ,KAAK;IACnD,SAAkB,IAAI,gCAAgC;IACtD,QAAQ,CAAC,IAAI,2CAA2C;;CASzD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/services",
3
- "version": "12.1.0",
3
+ "version": "12.2.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",
package/src/index.ts CHANGED
@@ -180,6 +180,10 @@ export {
180
180
  extractErrorMessage,
181
181
  } from './ui/utils/errorHandlers';
182
182
  export type { HandleAuthErrorOptions } from './ui/utils/errorHandlers';
183
+ // Thrown by `signInWithPassword` / `signIn` on a cross-apex web RP, where a
184
+ // direct (non-IdP) sign-in would not survive a page reload — apps catch it to
185
+ // route the user to the durable "Continue with Oxy" IdP flow.
186
+ export { CrossApexDirectSignInError, isCrossApexWeb } from './utils/crossApex';
183
187
 
184
188
  // ---------------------------------------------------------------------------
185
189
  // File filtering
@@ -30,6 +30,7 @@ import { useOxy } from '../context/OxyContext';
30
30
  import OxyLogo from './OxyLogo';
31
31
  import AnotherDeviceQR from './AnotherDeviceQR';
32
32
  import { useOxyAuthSession, OXY_ACCOUNTS_WEB_URL } from '../hooks/useOxyAuthSession';
33
+ import { isCrossApexWeb } from '../../utils/crossApex';
33
34
 
34
35
  // Store for modal visibility with subscription support
35
36
  let modalVisible = false;
@@ -110,6 +111,12 @@ const SignInModalContent: React.FC<SignInModalContentProps> = ({
110
111
  { onSignedIn: hideSignInModal },
111
112
  );
112
113
 
114
+ // On a cross-apex web RP, only the "Continue with Oxy" IdP popup establishes
115
+ // a durable `fedcm_session`. The Commons-app handoffs (same-device deep-link
116
+ // + cross-device QR) approve OUTSIDE the browser, so they leave no IdP
117
+ // session and the user would be logged out on reload — hide them there.
118
+ const crossApexWeb = isCrossApexWeb();
119
+
113
120
  // Entrance animation.
114
121
  const opacity = useSharedValue(0);
115
122
  const scale = useSharedValue(0.96);
@@ -194,8 +201,9 @@ const SignInModalContent: React.FC<SignInModalContentProps> = ({
194
201
 
195
202
  {/* Same-device "Sign in with Oxy" handoff — deep-links into the
196
203
  native Oxy app to approve. Shown only when the handoff backend
197
- returned a payload. */}
198
- {qrPayload && (
204
+ returned a payload, and never on a cross-apex web RP (the
205
+ approval happens outside the browser → no durable session). */}
206
+ {!crossApexWeb && qrPayload && (
199
207
  <Button
200
208
  variant="secondary"
201
209
  onPress={openSameDeviceApproval}
@@ -216,10 +224,14 @@ const SignInModalContent: React.FC<SignInModalContentProps> = ({
216
224
  </View>
217
225
  )}
218
226
 
219
- {/* Collapsed "sign in on another device" QR disclosure */}
220
- <View style={styles.qrSection}>
221
- <AnotherDeviceQR qrData={qrData} qrPayload={qrPayload} />
222
- </View>
227
+ {/* Collapsed "sign in on another device" QR disclosure. Hidden on
228
+ a cross-apex web RP: a remote Commons-app approval completes via
229
+ the device-flow claim, which plants no `fedcm_session`. */}
230
+ {!crossApexWeb && (
231
+ <View style={styles.qrSection}>
232
+ <AnotherDeviceQR qrData={qrData} qrPayload={qrPayload} />
233
+ </View>
234
+ )}
223
235
  </>
224
236
  )}
225
237
 
@@ -53,6 +53,7 @@ import { useAccountStore } from '../stores/accountStore';
53
53
  import { logger as loggerUtil } from '@oxyhq/core';
54
54
  import { useWebSSO, isWebBrowser } from '../hooks/useWebSSO';
55
55
  import { buildSilentGuardKey } from '../../utils/silentGuardKey';
56
+ import { isCrossApexWeb, CrossApexDirectSignInError } from '../../utils/crossApex';
56
57
  import { createInSessionRefreshHandler, startTokenRefreshScheduler } from './inSessionTokenRefresh';
57
58
  import { mintSessionViaPerApexIframe, selectActiveRefreshAccount } from './silentSessionRestore';
58
59
 
@@ -1418,14 +1419,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1418
1419
  // step that prevents the re-bounce loop: when it finds a session,
1419
1420
  // the terminal `sso-bounce` never fires.
1420
1421
  //
1421
- // The instance is configured with `authWebUrl=auth.oxy.so` (central,
1422
- // for the bounce + FedCM), so we explicitly point the iframe at the
1423
- // per-apex host via `autoDetectAuthWebUrl()` and `silentSignIn`'s
1424
- // `authWebUrlOverride`. On a `*.oxy.so` RP the per-apex host IS the
1425
- // central host (`auth.oxy.so`), so this is a same-host no-op-
1426
- // equivalent. When auto-detection bails (localhost/IP/single-label)
1427
- // there is no per-apex IdP and the step skips. Web only; on native
1428
- // `isWebBrowser()` gates it off, so native never runs an iframe.
1422
+ // The per-apex iframe mint itself lives in
1423
+ // `mintSessionViaPerApexIframe` (shared verbatim with the in-session
1424
+ // refresh handler so the two paths can never drift): it points the
1425
+ // iframe at `autoDetectAuthWebUrl()` and skips when there is no
1426
+ // per-apex IdP (localhost/IP/single-label/off-browser). Web only; on
1427
+ // native `isWebBrowser()` gates it off, so native never runs an
1428
+ // iframe.
1429
1429
  //
1430
1430
  // FIX-B: additionally skipped when `stored-session` already won.
1431
1431
  // FIX-D: bounded by `SILENT_IFRAME_TIMEOUT` (plus `iframe.onerror`
@@ -1777,6 +1777,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1777
1777
  password: string,
1778
1778
  opts?: { deviceName?: string; deviceFingerprint?: string },
1779
1779
  ): Promise<PasswordSignInResult> => {
1780
+ // On a cross-apex web RP a direct password sign-in mints a bearer against
1781
+ // the Oxy API but establishes no `fedcm_session`, so the session would be
1782
+ // lost on reload. Refuse it and direct the app to the durable IdP popup
1783
+ // ("Continue with Oxy"). Native and same-apex `*.oxy.so` are unaffected.
1784
+ if (isCrossApexWeb()) {
1785
+ throw new CrossApexDirectSignInError();
1786
+ }
1780
1787
  const response = await oxyServices.signIn(
1781
1788
  identifier,
1782
1789
  password,
@@ -10,6 +10,7 @@ import type { OxyServices } from '@oxyhq/core';
10
10
  import { SignatureService } from '@oxyhq/core';
11
11
  import { isWebBrowser } from '../../hooks/useWebSSO';
12
12
  import { clearActiveAuthuser, clearSsoBounceState } from '../../utils/activeAuthuser';
13
+ import { isCrossApexWeb, CrossApexDirectSignInError } from '../../../utils/crossApex';
13
14
 
14
15
  export interface UseAuthOperationsOptions {
15
16
  oxyServices: OxyServices;
@@ -203,6 +204,13 @@ export const useAuthOperations = ({
203
204
  */
204
205
  const signIn = useCallback(
205
206
  async (publicKey: string, deviceName?: string): Promise<User> => {
207
+ // On a cross-apex web RP a direct public-key sign-in mints a bearer against
208
+ // the Oxy API but establishes no `fedcm_session`, so the session would be
209
+ // lost on reload. Refuse it and direct the app to the durable IdP popup
210
+ // ("Continue with Oxy"). Native and same-apex `*.oxy.so` are unaffected.
211
+ if (isCrossApexWeb()) {
212
+ throw new CrossApexDirectSignInError();
213
+ }
206
214
  setAuthState({ isLoading: true, error: null });
207
215
 
208
216
  try {
@@ -15,10 +15,10 @@
15
15
  *
16
16
  * 1. {@link createInSessionRefreshHandler} — an `AuthRefreshHandler` installed
17
17
  * on the owner client. It re-mints a fresh access token WITHOUT a page
18
- * reload by reusing the SAME durable per-apex silent-restore arms cold boot
19
- * uses (in order; first that yields a token wins). The linked client
20
- * (`createLinkedClient`) inherits the fix for free — its refresh delegates
21
- * back to the owner's `refreshAccessToken`.
18
+ * reload by composing the SAME silent-restore primitives cold boot uses
19
+ * ({@link mintSessionViaPerApexIframe}, {@link selectActiveRefreshAccount})
20
+ * not a copy. The linked client (`createLinkedClient`) inherits the fix for
21
+ * free: its refresh delegates back to the owner's `refreshAccessToken`.
22
22
  *
23
23
  * 2. {@link startTokenRefreshScheduler} — a proactive scheduler that refreshes
24
24
  * ~{@link TOKEN_REFRESH_LEAD_MS} before expiry (and on web tab-focus / native
@@ -35,10 +35,11 @@ import type {
35
35
  AuthRefreshHandler,
36
36
  AuthRefreshReason,
37
37
  } from '@oxyhq/core';
38
- import { autoDetectAuthWebUrl, logger as loggerUtil } from '@oxyhq/core';
38
+ import { logger as loggerUtil } from '@oxyhq/core';
39
39
  import { AppState, type AppStateStatus } from 'react-native';
40
40
  import { isWebBrowser } from '../hooks/useWebSSO';
41
41
  import { readActiveAuthuser } from '../utils/activeAuthuser';
42
+ import { mintSessionViaPerApexIframe, selectActiveRefreshAccount } from './silentSessionRestore';
42
43
 
43
44
  /**
44
45
  * Per-arm fail-fast budget (ms) for the web first-party `/auth/silent` iframe
@@ -67,114 +68,97 @@ const COOKIE_REFRESH_TIMEOUT = 4000;
67
68
  */
68
69
  export const TOKEN_REFRESH_LEAD_MS = 60_000;
69
70
 
70
- function debugRefresh(message: string, reason: AuthRefreshReason, error?: unknown): void {
71
- if (__DEV__) {
72
- loggerUtil.debug(
73
- message,
74
- { component: 'inSessionTokenRefresh', method: 'authRefreshHandler', reason },
75
- error,
76
- );
77
- }
78
- }
71
+ /**
72
+ * A single refresh arm: attempts one silent-restore mechanism and resolves to
73
+ * the freshly planted access token, or `null` to fall through to the next arm.
74
+ */
75
+ type RefreshArm = () => Promise<string | null>;
79
76
 
80
77
  /**
81
78
  * Build the in-session `AuthRefreshHandler` for the owner client.
82
79
  *
83
- * Arms (first to yield a fresh token wins; each is bounded and falls through on
84
- * failure). Every arm plants the fresh token internally, so on success we read
85
- * it back via `getAccessToken()`:
80
+ * Arms run in an explicit order; the first to plant a token wins. Each arm
81
+ * resolves to the planted token (read back via `getAccessToken()` the
82
+ * underlying SDK call plants it internally) or `null`. A throw in one arm is
83
+ * logged and treated as `null` so the chain continues.
86
84
  *
87
85
  * NATIVE (Expo): shared cross-app identity key re-mint
88
86
  * (`signInWithSharedIdentity` → challenge→sign→verify plants the tokens).
89
87
  * The ONLY silent native arm (mirrors cold boot's `shared-key-signin`); the
90
- * `/auth/silent` web iframe is NEVER attempted on native. Returns `null`
88
+ * `/auth/silent` web iframe is NEVER attempted on native. Resolves to `null`
91
89
  * when the device holds no shared identity (e.g. a password-only native
92
90
  * sign-in) so a genuinely dead session reconciles to logged-out rather than
93
91
  * staying a zombie.
94
92
  *
95
93
  * WEB, in order:
96
- * 1. First-party `/auth/silent` iframe at the per-apex IdP
97
- * (`silentSignIn` with `authWebUrlOverride = autoDetectAuthWebUrl()`).
98
- * The durable cross-apex path: the iframe reads the first-party
99
- * `fedcm_session` cookie on `auth.<apex>` and mints a fresh Oxy token. No
100
- * top-level navigation → works in a backgrounded tab. On a `*.oxy.so`
101
- * app the per-apex host IS the central host, so this also covers
102
- * same-apex.
94
+ * 1. Per-apex `/auth/silent` iframe {@link mintSessionViaPerApexIframe}
95
+ * (shared verbatim with cold boot). The durable cross-apex path; also
96
+ * covers `*.oxy.so` (the per-apex host IS the central host there).
103
97
  * 2. FedCM silent re-auth (Chrome) — `silentSignInWithFedCM`.
104
- * 3. Same-apex refresh cookie — `refreshAllSessions`. On `*.oxy.so` the
105
- * httpOnly `oxy_rt_${n}` cookies ride along; we plant the active
106
- * account's rotated token. On a cross-apex RP it returns `{accounts:[]}`
107
- * and is a clean no-op. Unlike the cold-boot cookie restore this does NOT
108
- * rebuild multi-session state — an in-session refresh only needs a fresh
109
- * bearer.
98
+ * 3. Same-apex refresh cookie — `refreshAllSessions` + the shared
99
+ * {@link selectActiveRefreshAccount}; plant the active account's rotated
100
+ * token. On a cross-apex RP it returns `{accounts:[]}` (clean no-op).
101
+ * Unlike the cold-boot cookie restore this does NOT rebuild multi-session
102
+ * state — an in-session refresh only needs a fresh bearer.
110
103
  *
111
- * NO RECURSION: none of these arms issue requests through the authed client's
104
+ * NO RECURSION: no arm issues a request through the authed client's
112
105
  * `refreshAccessToken` path. The iframe/FedCM transports are postMessage /
113
106
  * credential APIs; the follow-up `/session/user` fetch inside `silentSignIn`
114
107
  * runs against the just-planted FULL-TTL token (≫ the preflight lead), so it
115
- * never re-enters the refresh path; `refreshAllSessions` uses a raw `fetch` with
116
- * `credentials:'include'`.
108
+ * never re-enters the refresh path; `refreshAllSessions` uses a raw `fetch`.
117
109
  */
118
110
  export function createInSessionRefreshHandler(oxyServices: OxyServices): AuthRefreshHandler {
119
- return async (reason: AuthRefreshReason): Promise<string | null> => {
120
- if (!isWebBrowser()) {
121
- try {
122
- const session = await oxyServices.signInWithSharedIdentity?.();
123
- if (session) {
124
- // `verifyChallenge` inside `signInWithSharedIdentity` already planted
125
- // the fresh tokens; read the planted access token back out.
126
- return oxyServices.getAccessToken();
127
- }
128
- } catch (error) {
129
- debugRefresh('Native shared-key in-session refresh failed', reason, error);
111
+ const runArm = async (label: string, reason: AuthRefreshReason, arm: RefreshArm): Promise<string | null> => {
112
+ try {
113
+ return await arm();
114
+ } catch (error) {
115
+ if (__DEV__) {
116
+ loggerUtil.debug(
117
+ `In-session refresh arm "${label}" failed (falling through)`,
118
+ { component: 'inSessionTokenRefresh', method: 'authRefreshHandler', reason },
119
+ error,
120
+ );
130
121
  }
131
122
  return null;
132
123
  }
124
+ };
133
125
 
134
- // WEB arm 1 first-party silent iframe at the per-apex IdP.
135
- try {
136
- const perApexAuthUrl = autoDetectAuthWebUrl();
137
- if (perApexAuthUrl) {
138
- const session = await oxyServices.silentSignIn?.({
139
- authWebUrlOverride: perApexAuthUrl,
140
- timeout: SILENT_IFRAME_REFRESH_TIMEOUT,
141
- });
142
- if (session) {
143
- return oxyServices.getAccessToken();
144
- }
126
+ const webArms: Array<[string, RefreshArm]> = [
127
+ ['silent-iframe', async () =>
128
+ (await mintSessionViaPerApexIframe(oxyServices, SILENT_IFRAME_REFRESH_TIMEOUT))
129
+ ? oxyServices.getAccessToken()
130
+ : null,
131
+ ],
132
+ ['fedcm-silent', async () => {
133
+ if (oxyServices.isFedCMSupported?.() !== true) {
134
+ return null;
145
135
  }
146
- } catch (error) {
147
- debugRefresh('Silent-iframe in-session refresh failed', reason, error);
148
- }
149
-
150
- // WEB arm 2 — FedCM silent re-auth (Chrome).
151
- try {
152
- if (oxyServices.isFedCMSupported?.() === true) {
153
- const session = await oxyServices.silentSignInWithFedCM?.();
154
- if (session) {
155
- return oxyServices.getAccessToken();
156
- }
136
+ return (await oxyServices.silentSignInWithFedCM?.()) ? oxyServices.getAccessToken() : null;
137
+ }],
138
+ ['refresh-cookie', async () => {
139
+ const snapshot = await oxyServices.refreshAllSessions({ timeout: COOKIE_REFRESH_TIMEOUT });
140
+ if (snapshot.accounts.length === 0) {
141
+ return null;
157
142
  }
158
- } catch (error) {
159
- debugRefresh('FedCM-silent in-session refresh failed', reason, error);
143
+ const active = selectActiveRefreshAccount(snapshot.accounts, readActiveAuthuser());
144
+ oxyServices.httpService.setTokens(active.accessToken);
145
+ return oxyServices.getAccessToken();
146
+ }],
147
+ ];
148
+
149
+ return async (reason: AuthRefreshReason): Promise<string | null> => {
150
+ if (!isWebBrowser()) {
151
+ return runArm('native-shared-key', reason, async () =>
152
+ (await oxyServices.signInWithSharedIdentity?.()) ? oxyServices.getAccessToken() : null,
153
+ );
160
154
  }
161
155
 
162
- // WEB arm 3 same-apex refresh cookie.
163
- try {
164
- const snapshot = await oxyServices.refreshAllSessions({ timeout: COOKIE_REFRESH_TIMEOUT });
165
- if (snapshot.accounts.length > 0) {
166
- const persistedAuthuser = readActiveAuthuser();
167
- const active =
168
- (persistedAuthuser !== null
169
- ? snapshot.accounts.find((account) => account.authuser === persistedAuthuser)
170
- : undefined) ?? snapshot.accounts[0];
171
- oxyServices.httpService.setTokens(active.accessToken);
172
- return oxyServices.getAccessToken();
156
+ for (const [label, arm] of webArms) {
157
+ const token = await runArm(label, reason, arm);
158
+ if (token) {
159
+ return token;
173
160
  }
174
- } catch (error) {
175
- debugRefresh('Refresh-cookie in-session refresh failed', reason, error);
176
161
  }
177
-
178
162
  return null;
179
163
  };
180
164
  }
@@ -194,14 +178,15 @@ export interface TokenRefreshSchedulerHandle {
194
178
  * current access token's `exp`, calling `httpService.refreshAccessToken`
195
179
  * ('preflight') — which runs the installed handler and is deduped + cooldown-
196
180
  * guarded. After every attempt it reschedules from the (possibly rotated) token.
197
- * It also reschedules whenever the token changes (`onTokensChanged`) and, on
198
- * web tab-focus / native app-foreground, refreshes immediately if already inside
199
- * the lead window (a long-hidden tab throttles timers, so the token can be
200
- * expired on return).
181
+ * It also reschedules whenever the token changes (`onTokensChanged` so a
182
+ * sign-out that clears the token cancels the timer) and, on web tab-focus /
183
+ * native app-foreground, refreshes immediately if already inside the lead window
184
+ * (a long-hidden tab throttles timers, so the token can be expired on return).
201
185
  *
202
- * No-ops cleanly when there is no token, an opaque/no-`exp` token, or the host
203
- * lacks `getAccessTokenExpiry` (older stubs) in those cases the reactive 401
204
- * path remains the only refresh trigger.
186
+ * The `exp` is derived directly from the JWT via `getAccessTokenExpiry()`. No-ops
187
+ * cleanly when there is no token, an opaque/no-`exp` token, or the host lacks
188
+ * `getAccessTokenExpiry` (older stubs) — the reactive 401 path stays the only
189
+ * refresh trigger in those cases.
205
190
  */
206
191
  export function startTokenRefreshScheduler(oxyServices: OxyServices): TokenRefreshSchedulerHandle {
207
192
  let disposed = false;
@@ -29,6 +29,7 @@ import OxyLogo from '../components/OxyLogo';
29
29
  import AnotherDeviceQR from '../components/AnotherDeviceQR';
30
30
  import LoadingState from '../components/LoadingState';
31
31
  import { useOxyAuthSession, OXY_ACCOUNTS_WEB_URL } from '../hooks/useOxyAuthSession';
32
+ import { isCrossApexWeb } from '../../utils/crossApex';
32
33
 
33
34
  const OxyAuthScreen: React.FC<BaseScreenProps> = ({ goBack, onAuthenticated }) => {
34
35
  const bloomTheme = useTheme();
@@ -41,6 +42,13 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({ goBack, onAuthenticated }) =
41
42
  { onSignedIn: onAuthenticated },
42
43
  );
43
44
 
45
+ // On a cross-apex web RP, only the "Continue with Oxy" IdP popup establishes a
46
+ // durable `fedcm_session`. The Commons-app handoffs (same-device deep-link +
47
+ // cross-device QR) approve OUTSIDE the browser, so they leave no IdP session
48
+ // and the user would be logged out on reload — hide them there. Off-browser
49
+ // (native) this is always `false`, so the bottom sheet is unchanged.
50
+ const crossApexWeb = isCrossApexWeb();
51
+
44
52
  if (isLoading) {
45
53
  return (
46
54
  <View className="flex-1 items-center justify-center bg-bg">
@@ -104,8 +112,10 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({ goBack, onAuthenticated }) =
104
112
  </Button>
105
113
 
106
114
  {/* Same-device "Sign in with Oxy" handoff — deep-links into the native Oxy
107
- app to approve. Shown only when the handoff backend returned a payload. */}
108
- {qrPayload && (
115
+ app to approve. Shown only when the handoff backend returned a payload,
116
+ and never on a cross-apex web RP (approval happens outside the browser
117
+ → no durable session). */}
118
+ {!crossApexWeb && qrPayload && (
109
119
  <Button
110
120
  variant="secondary"
111
121
  size="large"
@@ -130,10 +140,14 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({ goBack, onAuthenticated }) =
130
140
  </View>
131
141
  )}
132
142
 
133
- {/* Collapsed "sign in on another device" QR disclosure */}
134
- <View className="w-full mt-space-24">
135
- <AnotherDeviceQR qrData={qrData} qrPayload={qrPayload} />
136
- </View>
143
+ {/* Collapsed "sign in on another device" QR disclosure. Hidden on a
144
+ cross-apex web RP: a remote Commons-app approval completes via the
145
+ device-flow claim, which plants no `fedcm_session`. */}
146
+ {!crossApexWeb && (
147
+ <View className="w-full mt-space-24">
148
+ <AnotherDeviceQR qrData={qrData} qrPayload={qrPayload} />
149
+ </View>
150
+ )}
137
151
 
138
152
  {/* Footer — create an account */}
139
153
  <View className="flex-row flex-wrap justify-center items-center mt-space-24">
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Cross-apex web detection for sign-in durability gating.
3
+ *
4
+ * On a web Relying Party whose registrable apex differs from the central Oxy
5
+ * Identity Provider apex (`oxy.so`), the ONLY sign-in completion that survives a
6
+ * page reload is the interactive "Continue with Oxy" IdP flow, because it is the
7
+ * only one that establishes a first-party `fedcm_session` cookie at
8
+ * `auth.<apex>` — the anchor the cross-domain cold-boot restore reads on reload.
9
+ *
10
+ * The other sign-in completions the SDK can drive do NOT plant a `fedcm_session`:
11
+ * - password / public-key sign-in mints a bearer directly against the Oxy API
12
+ * (its refresh cookie is host-scoped to `api.oxy.so` and `SameSite=Lax`, so
13
+ * it is unreachable cross-site from the RP), and
14
+ * - the Commons-app device-flow handoff (cross-device QR / same-device
15
+ * deep-link) is approved OUTSIDE the browser, so no IdP browser session is
16
+ * established.
17
+ * On a cross-apex RP each of these leaves the user signed in for the current
18
+ * page only — a reload logs them out. See OxyHQServices AGENTS.md
19
+ * "Auth / Session Contract".
20
+ *
21
+ * Same-apex `*.oxy.so` apps (e.g. `accounts.oxy.so`) are first-party with
22
+ * `api.oxy.so`: their refresh cookie rides same-site requests so reload restore
23
+ * works without `fedcm_session`, and they are therefore NOT gated.
24
+ *
25
+ * Returns `false` off-browser (React Native has no `window.location`) and for
26
+ * hosts without a registrable apex (localhost / raw IP / single-label dev
27
+ * hosts), so native and local development keep every sign-in method.
28
+ */
29
+
30
+ import { registrableApex, CENTRAL_IDP_APEX } from '@oxyhq/core';
31
+
32
+ /**
33
+ * Whether the given host is a web RP on a registrable apex other than the
34
+ * central Oxy IdP apex (`oxy.so`). See the module doc for why this gates the
35
+ * non-durable sign-in paths.
36
+ *
37
+ * @param hostname - The host to classify. Defaults to the current
38
+ * `window.location.hostname`; resolves to `undefined` off-browser (React
39
+ * Native / SSR), which yields `false`. The explicit parameter mirrors
40
+ * `autoDetectAuthWebUrl(location?)` and keeps the predicate unit-testable
41
+ * without manipulating the global `window.location`.
42
+ */
43
+ export function isCrossApexWeb(
44
+ hostname: string | undefined = typeof window !== 'undefined'
45
+ ? window.location?.hostname
46
+ : undefined,
47
+ ): boolean {
48
+ if (!hostname) {
49
+ return false;
50
+ }
51
+ const apex = registrableApex(hostname);
52
+ return apex !== null && apex !== CENTRAL_IDP_APEX;
53
+ }
54
+
55
+ /**
56
+ * Thrown when an app attempts a direct (non-IdP) sign-in — password or
57
+ * public-key — on a cross-apex web RP, where such a sign-in would not survive a
58
+ * page reload because no `fedcm_session` is established (see {@link isCrossApexWeb}).
59
+ *
60
+ * Apps on a cross-apex apex must sign in through the interactive
61
+ * "Continue with Oxy" IdP flow (`OxySignInButton` / `showSignInModal()`), which
62
+ * plants the durable session.
63
+ */
64
+ export class CrossApexDirectSignInError extends Error {
65
+ override readonly name = 'CrossApexDirectSignInError';
66
+ readonly code = 'CROSS_APEX_DIRECT_SIGN_IN_UNSUPPORTED';
67
+
68
+ constructor() {
69
+ super(
70
+ 'Direct sign-in is unavailable on this domain because the session would ' +
71
+ 'not survive a page reload. Use "Continue with Oxy" to sign in through ' +
72
+ 'the Oxy identity provider.',
73
+ );
74
+ }
75
+ }