@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.
- package/lib/commonjs/index.js +17 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/SignInModal.js +9 -2
- package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +15 -8
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +8 -0
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/commonjs/ui/context/inSessionTokenRefresh.js +66 -90
- package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +1 -1
- package/lib/commonjs/ui/screens/OxyAuthScreen.js +10 -2
- package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/commonjs/utils/crossApex.js +74 -0
- package/lib/commonjs/utils/crossApex.js.map +1 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/SignInModal.js +9 -2
- package/lib/module/ui/components/SignInModal.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +15 -8
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js +8 -0
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/context/inSessionTokenRefresh.js +67 -91
- package/lib/module/ui/context/inSessionTokenRefresh.js.map +1 -1
- package/lib/module/ui/screens/OxyAuthScreen.js +10 -2
- package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/module/utils/crossApex.js +69 -0
- package/lib/module/utils/crossApex.js.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +1 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/SignInModal.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +27 -31
- package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/utils/crossApex.d.ts +55 -0
- package/lib/typescript/commonjs/utils/crossApex.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +1 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +27 -31
- package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
- package/lib/typescript/module/utils/crossApex.d.ts +55 -0
- package/lib/typescript/module/utils/crossApex.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/ui/components/SignInModal.tsx +18 -6
- package/src/ui/context/OxyContext.tsx +15 -8
- package/src/ui/context/hooks/useAuthOperations.ts +8 -0
- package/src/ui/context/inSessionTokenRefresh.ts +75 -90
- package/src/ui/screens/OxyAuthScreen.tsx +20 -6
- 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
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
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
|
|
19
|
-
*
|
|
20
|
-
* (`createLinkedClient`) inherits the fix for
|
|
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 {
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
84
|
-
*
|
|
85
|
-
* it
|
|
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.
|
|
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.
|
|
97
|
-
* (
|
|
98
|
-
*
|
|
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
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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`
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* path
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
}
|