@oxyhq/core 5.2.1 → 5.4.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/CrossDomainAuth.js +32 -42
- package/dist/cjs/index.js +24 -2
- package/dist/cjs/mixins/OxyServices.sso.js +36 -0
- package/dist/cjs/mixins/OxyServices.user.js +50 -0
- package/dist/cjs/server/index.js +13 -1
- package/dist/cjs/session/SessionClient.js +152 -0
- package/dist/cjs/session/createSessionClient.js +26 -0
- package/dist/cjs/session/projectSessionState.js +75 -0
- package/dist/cjs/session/sessionClientHost.js +30 -0
- package/dist/cjs/session/socketLoader.js +55 -0
- package/dist/cjs/utils/ssoBounce.js +9 -9
- package/dist/cjs/utils/ssoEstablish.js +110 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/CrossDomainAuth.js +32 -42
- package/dist/esm/index.js +14 -0
- package/dist/esm/mixins/OxyServices.sso.js +36 -0
- package/dist/esm/mixins/OxyServices.user.js +50 -0
- package/dist/esm/server/index.js +10 -0
- package/dist/esm/session/SessionClient.js +148 -0
- package/dist/esm/session/createSessionClient.js +23 -0
- package/dist/esm/session/projectSessionState.js +69 -0
- package/dist/esm/session/sessionClientHost.js +27 -0
- package/dist/esm/session/socketLoader.js +19 -0
- package/dist/esm/utils/ssoBounce.js +9 -9
- package/dist/esm/utils/ssoEstablish.js +107 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/CrossDomainAuth.d.ts +33 -13
- package/dist/types/index.d.ts +7 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +24 -0
- package/dist/types/mixins/OxyServices.user.d.ts +30 -0
- package/dist/types/server/index.d.ts +2 -0
- package/dist/types/session/SessionClient.d.ts +55 -0
- package/dist/types/session/createSessionClient.d.ts +23 -0
- package/dist/types/session/projectSessionState.d.ts +43 -0
- package/dist/types/session/sessionClientHost.d.ts +18 -0
- package/dist/types/session/socketLoader.d.ts +9 -0
- package/dist/types/utils/ssoBounce.d.ts +9 -9
- package/dist/types/utils/ssoEstablish.d.ts +85 -0
- package/package.json +1 -1
- package/src/CrossDomainAuth.ts +33 -44
- package/src/__tests__/crossDomainAuth.test.ts +33 -16
- package/src/index.ts +24 -0
- package/src/mixins/OxyServices.sso.ts +55 -0
- package/src/mixins/OxyServices.user.ts +54 -0
- package/src/mixins/__tests__/sso.test.ts +41 -0
- package/src/server/index.ts +12 -0
- package/src/session/SessionClient.ts +187 -0
- package/src/session/__tests__/SessionClient.diagnostics.test.ts +90 -0
- package/src/session/__tests__/SessionClient.httpIntegration.test.ts +65 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +80 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +133 -0
- package/src/session/__tests__/SessionClient.state.test.ts +64 -0
- package/src/session/__tests__/sessionIntegration.test.ts +202 -0
- package/src/session/createSessionClient.ts +31 -0
- package/src/session/projectSessionState.ts +83 -0
- package/src/session/sessionClientHost.ts +32 -0
- package/src/session/socketLoader.ts +28 -0
- package/src/utils/__tests__/ssoEstablish.test.ts +204 -0
- package/src/utils/ssoBounce.ts +9 -9
- package/src/utils/ssoEstablish.ts +174 -0
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-Domain Authentication Helper
|
|
3
3
|
*
|
|
4
|
-
* Provides a simplified API for cross-domain SSO authentication
|
|
5
|
-
*
|
|
4
|
+
* Provides a simplified API for cross-domain SSO authentication. The
|
|
5
|
+
* automatic sign-in path uses a full-page redirect through the central IdP
|
|
6
|
+
* (`auth.oxy.so`) — a tokenless, universal mechanism that works in every
|
|
7
|
+
* browser.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* FedCM (`signInWithFedCM`) is intentionally NOT part of the automatic
|
|
10
|
+
* (`'auto'`) path: it is a Chrome-only browser API, and a misconfigured or
|
|
11
|
+
* unreachable FedCM endpoint fails fast and silently, which — combined with a
|
|
12
|
+
* caller's auth-guard effect re-invoking `signIn()` whenever the user is still
|
|
13
|
+
* unauthenticated — produced a real production incident (an accelerating
|
|
14
|
+
* `autoSignIn` → FedCM-fails → redirect retry loop). `signInWithFedCM` remains
|
|
15
|
+
* available for callers that want to opt into it EXPLICITLY
|
|
16
|
+
* (`signIn({ method: 'fedcm' })`).
|
|
9
17
|
*
|
|
10
18
|
* Usage:
|
|
11
19
|
* ```typescript
|
|
@@ -13,7 +21,7 @@
|
|
|
13
21
|
*
|
|
14
22
|
* const auth = new CrossDomainAuth(oxyServices);
|
|
15
23
|
*
|
|
16
|
-
* // Automatic method selection
|
|
24
|
+
* // Automatic method selection (always redirect)
|
|
17
25
|
* const session = await auth.signIn();
|
|
18
26
|
*
|
|
19
27
|
* // Or use a specific method
|
|
@@ -26,11 +34,11 @@ export class CrossDomainAuth {
|
|
|
26
34
|
this.oxyServices = oxyServices;
|
|
27
35
|
}
|
|
28
36
|
/**
|
|
29
|
-
* Sign in with automatic method selection
|
|
37
|
+
* Sign in with automatic method selection.
|
|
30
38
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
39
|
+
* Auto mode always uses the full-page redirect (see the class doc comment
|
|
40
|
+
* for why FedCM was removed from this path). Pass `{ method: 'fedcm' }` to
|
|
41
|
+
* opt into FedCM explicitly.
|
|
34
42
|
*
|
|
35
43
|
* @param options - Authentication options
|
|
36
44
|
* @returns Session with user data and access token
|
|
@@ -48,22 +56,18 @@ export class CrossDomainAuth {
|
|
|
48
56
|
return this.autoSignIn(options);
|
|
49
57
|
}
|
|
50
58
|
/**
|
|
51
|
-
* Automatic sign-in
|
|
59
|
+
* Automatic sign-in.
|
|
60
|
+
*
|
|
61
|
+
* Goes straight to the full-page redirect — the sole automatic method.
|
|
62
|
+
* FedCM is deliberately NOT attempted here (see the class doc comment):
|
|
63
|
+
* it is Chrome-only, and its fast/silent failure mode combined with a
|
|
64
|
+
* caller's auth-guard effect re-invoking `signIn()` produced a real
|
|
65
|
+
* production sign-in loop. Use `signIn({ method: 'fedcm' })` to opt in
|
|
66
|
+
* explicitly.
|
|
52
67
|
*
|
|
53
68
|
* @private
|
|
54
69
|
*/
|
|
55
70
|
async autoSignIn(options) {
|
|
56
|
-
// 1. Try FedCM first (best UX, most modern)
|
|
57
|
-
if (this.isFedCMSupported()) {
|
|
58
|
-
try {
|
|
59
|
-
options.onMethodSelected?.('fedcm');
|
|
60
|
-
return await this.signInWithFedCM(options);
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
logger.warn('FedCM failed, falling back to redirect', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
// 2. Fallback to redirect (always works)
|
|
67
71
|
options.onMethodSelected?.('redirect');
|
|
68
72
|
this.signInWithRedirect(options);
|
|
69
73
|
return null;
|
|
@@ -100,25 +104,13 @@ export class CrossDomainAuth {
|
|
|
100
104
|
/**
|
|
101
105
|
* Silent sign-in (check for existing session)
|
|
102
106
|
*
|
|
103
|
-
* Tries to automatically sign in without user interaction
|
|
104
|
-
*
|
|
107
|
+
* Tries to automatically sign in without user interaction, via the
|
|
108
|
+
* iframe-based silent auth against the per-apex `/auth/silent` IdP host.
|
|
109
|
+
* FedCM is deliberately NOT attempted here (see the class doc comment).
|
|
105
110
|
*
|
|
106
111
|
* @returns Session if user is already signed in, null otherwise
|
|
107
112
|
*/
|
|
108
113
|
async silentSignIn() {
|
|
109
|
-
// Try FedCM silent sign-in first (if supported)
|
|
110
|
-
if (this.isFedCMSupported()) {
|
|
111
|
-
try {
|
|
112
|
-
const session = await this.oxyServices.silentSignInWithFedCM();
|
|
113
|
-
if (session) {
|
|
114
|
-
return session;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
catch (error) {
|
|
118
|
-
logger.debug('FedCM silent sign-in did not resolve', { component: 'CrossDomainAuth', method: 'silentSignIn' }, error);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
// Fallback to iframe-based silent auth
|
|
122
114
|
try {
|
|
123
115
|
return await this.oxyServices.silentSignIn();
|
|
124
116
|
}
|
|
@@ -147,15 +139,13 @@ export class CrossDomainAuth {
|
|
|
147
139
|
/**
|
|
148
140
|
* Get recommended authentication method for current environment
|
|
149
141
|
*
|
|
142
|
+
* Redirect is the sole recommended automatic method — it works in every
|
|
143
|
+
* browser, unlike FedCM (Chrome-only). Callers that want FedCM must opt in
|
|
144
|
+
* explicitly via `signIn({ method: 'fedcm' })`.
|
|
145
|
+
*
|
|
150
146
|
* @returns Recommended method name and reason
|
|
151
147
|
*/
|
|
152
148
|
getRecommendedMethod() {
|
|
153
|
-
if (this.isFedCMSupported()) {
|
|
154
|
-
return {
|
|
155
|
-
method: 'fedcm',
|
|
156
|
-
reason: 'FedCM is supported - provides best UX with browser-native auth',
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
149
|
if (typeof window !== 'undefined') {
|
|
160
150
|
return {
|
|
161
151
|
method: 'redirect',
|
package/dist/esm/index.js
CHANGED
|
@@ -126,9 +126,23 @@ export { autoDetectAuthWebUrl, registrableApex } from './utils/fapiAutoDetect.js
|
|
|
126
126
|
export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl.js';
|
|
127
127
|
export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn.js';
|
|
128
128
|
export { generateSsoState } from './mixins/OxyServices.sso.js';
|
|
129
|
+
// Post-claim durable-session establish hop (web device-flow / QR sign-in).
|
|
130
|
+
export { establishIdpSessionAfterClaim } from './utils/ssoEstablish.js';
|
|
129
131
|
// SSO bounce — per-origin sessionStorage keys, bounce URL builder, predicates
|
|
130
132
|
export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoPriorSessionKey, ssoSignedOutKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, silentRestoreSuppressed, allowSsoBounce, } from './utils/ssoBounce.js';
|
|
131
133
|
export { runColdBoot } from './utils/coldBoot.js';
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Session sync (device-scoped multi-account session client)
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
export { SessionClient } from './session/SessionClient.js';
|
|
138
|
+
// Shared SessionClient integration layer: the host adapter, the pure
|
|
139
|
+
// DeviceSessionState projection helpers, and the client factory are defined
|
|
140
|
+
// ONCE here so `@oxyhq/services` and `@oxyhq/auth` both reuse them instead of
|
|
141
|
+
// duplicating a local copy. Each consumer supplies its own `TokenTransport`
|
|
142
|
+
// (native vs. web mint strategies differ) to `createSessionClient`.
|
|
143
|
+
export { createSessionClientHost } from './session/sessionClientHost.js';
|
|
144
|
+
export { createSessionClient } from './session/createSessionClient.js';
|
|
145
|
+
export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountIdsOf, } from './session/projectSessionState.js';
|
|
132
146
|
// API response contracts (request/response Zod schemas + inferred types) live in
|
|
133
147
|
// `@oxyhq/contracts` — the single source of truth shared by the backend and every
|
|
134
148
|
// client SDK. Import them directly from `@oxyhq/contracts`; `@oxyhq/core` does NOT
|
|
@@ -164,5 +164,41 @@ export function OxyServicesSsoMixin(Base) {
|
|
|
164
164
|
};
|
|
165
165
|
return session;
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Mint a server-formed `/sso/establish` URL for the caller's OWN session,
|
|
169
|
+
* bound to an approved RP `origin`.
|
|
170
|
+
*
|
|
171
|
+
* Bearer-authenticated (the session id is taken from the caller's own
|
|
172
|
+
* bearer, server-side — never from any argument). The server validates that
|
|
173
|
+
* `origin` is an approved client origin (and matches the request `Origin`),
|
|
174
|
+
* derives the per-apex IdP host (`auth.<apex>`), mints a short-lived HS256
|
|
175
|
+
* establish-token, and returns a fully-formed
|
|
176
|
+
* `https://<auth-host>/sso/establish?et=…&return_to=<origin>/__oxy/sso-callback&state=<state>`.
|
|
177
|
+
*
|
|
178
|
+
* Used AFTER a web device-flow claim to plant the durable first-party
|
|
179
|
+
* `fedcm_session` cookie so a reload can re-mint a token (see
|
|
180
|
+
* {@link establishIdpSessionAfterClaim}). Cache-free (a POST is never
|
|
181
|
+
* cached, but `cache: false` is explicit).
|
|
182
|
+
*
|
|
183
|
+
* @param origin - The RP origin (`window.location.origin`) to establish for.
|
|
184
|
+
* @param state - The CSRF state echoed back in the callback fragment; the
|
|
185
|
+
* caller persists the SAME value under `ssoStateKey(origin)` so the
|
|
186
|
+
* post-bounce `sso-return` step validates it.
|
|
187
|
+
*/
|
|
188
|
+
async requestSsoEstablishUrl(origin, state) {
|
|
189
|
+
if (typeof origin !== 'string' || origin.length === 0) {
|
|
190
|
+
throw this.handleError(new Error('requestSsoEstablishUrl requires a non-empty origin'));
|
|
191
|
+
}
|
|
192
|
+
if (typeof state !== 'string' || state.length === 0) {
|
|
193
|
+
throw this.handleError(new Error('requestSsoEstablishUrl requires a non-empty state'));
|
|
194
|
+
}
|
|
195
|
+
const response = await this.makeRequest('POST', '/sso/establish-token', { origin, state }, { cache: false });
|
|
196
|
+
if (!response ||
|
|
197
|
+
typeof response.establishUrl !== 'string' ||
|
|
198
|
+
response.establishUrl.length === 0) {
|
|
199
|
+
throw this.handleError(new Error('SSO establish-token returned no establishUrl'));
|
|
200
|
+
}
|
|
201
|
+
return { establishUrl: response.establishUrl };
|
|
202
|
+
}
|
|
167
203
|
};
|
|
168
204
|
}
|
|
@@ -630,6 +630,56 @@ export function OxyServicesUserMixin(Base) {
|
|
|
630
630
|
throw this.handleError(error);
|
|
631
631
|
}
|
|
632
632
|
}
|
|
633
|
+
/**
|
|
634
|
+
* Get the authenticated VIEWER's OWN mutual-follow user ids — the accounts the
|
|
635
|
+
* viewer follows that ALSO follow the viewer back (a bidirectional follow
|
|
636
|
+
* edge). The viewer is derived server-side from the SDK's auth token (never a
|
|
637
|
+
* param), so there is no target id to pass.
|
|
638
|
+
*
|
|
639
|
+
* Returns a bounded, lean list of ids meant to SEED a "Mutuals" feed (the
|
|
640
|
+
* consumer hydrates/ranks the posts itself) — distinct from
|
|
641
|
+
* {@link getUserMutuals}, which returns hydrated "followers you know" DTOs
|
|
642
|
+
* about ANOTHER profile. An anonymous caller resolves to an empty array.
|
|
643
|
+
*/
|
|
644
|
+
async getMutualUserIds(params) {
|
|
645
|
+
try {
|
|
646
|
+
const query = buildPaginationParams(params || {});
|
|
647
|
+
const response = await this.makeRequest('GET', '/users/mutual-ids', query, {
|
|
648
|
+
cache: true,
|
|
649
|
+
cacheTTL: 2 * 60 * 1000, // 2 minutes cache
|
|
650
|
+
});
|
|
651
|
+
return response.data || [];
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
throw this.handleError(error);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Get the authenticated VIEWER's bounded "follows-of-follows" user ids — the
|
|
659
|
+
* union of the accounts followed by the accounts the viewer follows (a
|
|
660
|
+
* two-hop walk of the follow graph), MINUS the viewer's own follows and the
|
|
661
|
+
* viewer themselves. The viewer is derived server-side from the SDK's auth
|
|
662
|
+
* token (never a param), so there is no target id to pass.
|
|
663
|
+
*
|
|
664
|
+
* Returns a bounded, lean list of ids meant to SEED a friends-of-friends
|
|
665
|
+
* feed (the consumer hydrates/ranks the posts itself), ordered by frequency
|
|
666
|
+
* (accounts followed by more of the viewer's follows first), then recency.
|
|
667
|
+
* An anonymous caller resolves to an empty array. Mirrors
|
|
668
|
+
* {@link getMutualUserIds}'s caching posture.
|
|
669
|
+
*/
|
|
670
|
+
async getFollowsOfFollowsIds(params) {
|
|
671
|
+
try {
|
|
672
|
+
const query = buildPaginationParams(params || {});
|
|
673
|
+
const response = await this.makeRequest('GET', '/users/follows-of-follows-ids', query, {
|
|
674
|
+
cache: true,
|
|
675
|
+
cacheTTL: 2 * 60 * 1000, // 2 minutes cache
|
|
676
|
+
});
|
|
677
|
+
return response.data || [];
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
throw this.handleError(error);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
633
683
|
/**
|
|
634
684
|
* Get notifications
|
|
635
685
|
*/
|
package/dist/esm/server/index.js
CHANGED
|
@@ -22,3 +22,13 @@ export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamErr
|
|
|
22
22
|
export { createOxyCors } from './cors.js';
|
|
23
23
|
// Constant-time secret comparison.
|
|
24
24
|
export { verifySecret } from './verifySecret.js';
|
|
25
|
+
// Registrable-apex (eTLD+1) derivation via the Public Suffix List — the SINGLE
|
|
26
|
+
// SOURCE OF TRUTH shared with the IdP worker and the client FAPI auto-detect.
|
|
27
|
+
// Pure host handling (no browser deps), so it is safe on the server subpath and
|
|
28
|
+
// lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
|
|
29
|
+
export { registrableApex } from '../utils/fapiAutoDetect.js';
|
|
30
|
+
// The single RP callback path the IdP redirects back to. A pure wire-contract
|
|
31
|
+
// constant (no browser deps at module top level), re-used server-side so the
|
|
32
|
+
// `/sso/establish-token` `return_to` cannot drift from what `/sso/establish`
|
|
33
|
+
// validates.
|
|
34
|
+
export { SSO_CALLBACK_PATH } from '../utils/ssoBounce.js';
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { deviceSessionStateSchema, deviceSessionSyncSchema, safeParseContract, } from '@oxyhq/contracts';
|
|
2
|
+
import { logger } from '../utils/loggerUtils.js';
|
|
3
|
+
import { getSocketIO } from './socketLoader.js';
|
|
4
|
+
export class SessionClient {
|
|
5
|
+
constructor(host, options = {}) {
|
|
6
|
+
this.host = host;
|
|
7
|
+
this.options = options;
|
|
8
|
+
this.state = null;
|
|
9
|
+
this.listeners = new Set();
|
|
10
|
+
this.socket = null;
|
|
11
|
+
this.tokenUnsub = null;
|
|
12
|
+
this.started = false;
|
|
13
|
+
}
|
|
14
|
+
getState() {
|
|
15
|
+
return this.state;
|
|
16
|
+
}
|
|
17
|
+
subscribe(listener) {
|
|
18
|
+
this.listeners.add(listener);
|
|
19
|
+
return () => {
|
|
20
|
+
this.listeners.delete(listener);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
notify() {
|
|
24
|
+
for (const listener of this.listeners) {
|
|
25
|
+
try {
|
|
26
|
+
listener(this.state);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
logger.error('[SessionClient] subscriber threw', error);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Validate + last-writer-wins by revision. Returns true if applied. */
|
|
34
|
+
applyState(raw) {
|
|
35
|
+
const next = safeParseContract(deviceSessionStateSchema, raw);
|
|
36
|
+
if (!next) {
|
|
37
|
+
logger.warn('[SessionClient] discarded invalid session state');
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (this.state && next.revision <= this.state.revision) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
this.state = next;
|
|
44
|
+
this.notify();
|
|
45
|
+
if (this.options.transport) {
|
|
46
|
+
void this.options.transport.ensureActiveToken(next).catch((error) => {
|
|
47
|
+
logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Validate `{ state, activeToken }`, apply the state, and plant the active token host-side.
|
|
54
|
+
* Token-planting is decoupled from whether `applyState` advanced the revision: a socket push
|
|
55
|
+
* followed by this same `GET /state` fetch returns the SAME revision (applyState no-ops), but
|
|
56
|
+
* the token still needs to be planted. The account-match guard rejects a stale response for an
|
|
57
|
+
* account that is no longer active.
|
|
58
|
+
*/
|
|
59
|
+
applySync(raw) {
|
|
60
|
+
const sync = safeParseContract(deviceSessionSyncSchema, raw);
|
|
61
|
+
if (!sync) {
|
|
62
|
+
const parsed = deviceSessionSyncSchema.safeParse(raw);
|
|
63
|
+
// Log field-level type diagnostics ONLY — never values. The payload carries tokens and
|
|
64
|
+
// session ids; issue.path/code and the invalid_type expected/received TYPE names are safe,
|
|
65
|
+
// but zod messages can embed offending values for other codes, so they are omitted.
|
|
66
|
+
const issues = parsed.success
|
|
67
|
+
? []
|
|
68
|
+
: parsed.error.issues.map((issue) => issue.code === 'invalid_type'
|
|
69
|
+
? { path: issue.path.join('.'), code: issue.code, expected: issue.expected, received: issue.received }
|
|
70
|
+
: { path: issue.path.join('.'), code: issue.code });
|
|
71
|
+
const keys = raw && typeof raw === 'object' ? Object.keys(raw) : [];
|
|
72
|
+
logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.applyState(sync.state);
|
|
76
|
+
if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
|
|
77
|
+
this.host.setTokens(sync.activeToken.accessToken);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async bootstrap() {
|
|
81
|
+
const res = await this.host.makeRequest('GET', '/session/device/state', undefined, { cache: false });
|
|
82
|
+
this.applySync(res);
|
|
83
|
+
}
|
|
84
|
+
async switchAccount(accountId) {
|
|
85
|
+
const res = await this.host.makeRequest('POST', '/session/device/switch', { accountId }, { cache: false });
|
|
86
|
+
this.applySync(res);
|
|
87
|
+
}
|
|
88
|
+
async signOut(target) {
|
|
89
|
+
const res = await this.host.makeRequest('POST', '/session/device/signout', target, { cache: false });
|
|
90
|
+
this.applySync(res);
|
|
91
|
+
}
|
|
92
|
+
async addCurrentAccount() {
|
|
93
|
+
const res = await this.host.makeRequest('POST', '/session/device/add', undefined, { cache: false });
|
|
94
|
+
this.applySync(res);
|
|
95
|
+
}
|
|
96
|
+
async start() {
|
|
97
|
+
if (this.started)
|
|
98
|
+
return;
|
|
99
|
+
this.started = true;
|
|
100
|
+
this.tokenUnsub = this.host.onTokensChanged((token) => {
|
|
101
|
+
if (token && this.socket && !this.socket.connected) {
|
|
102
|
+
this.socket.connect();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
await this.bootstrap();
|
|
106
|
+
await this.connectSocket();
|
|
107
|
+
}
|
|
108
|
+
stop() {
|
|
109
|
+
this.started = false;
|
|
110
|
+
if (this.tokenUnsub) {
|
|
111
|
+
this.tokenUnsub();
|
|
112
|
+
this.tokenUnsub = null;
|
|
113
|
+
}
|
|
114
|
+
if (this.socket) {
|
|
115
|
+
this.socket.disconnect();
|
|
116
|
+
this.socket = null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async connectSocket() {
|
|
120
|
+
const io = await getSocketIO();
|
|
121
|
+
if (!io) {
|
|
122
|
+
logger.warn('[SessionClient] no socket.io-client; running REST-only (no realtime sync)', { component: 'SessionClient' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!this.started)
|
|
126
|
+
return; // stopped while the dynamic import was in flight
|
|
127
|
+
const hasToken = Boolean(this.host.getAccessToken());
|
|
128
|
+
const socket = io(this.host.getBaseURL(), {
|
|
129
|
+
transports: ['websocket'],
|
|
130
|
+
autoConnect: hasToken,
|
|
131
|
+
auth: (cb) => {
|
|
132
|
+
cb({ token: this.host.getAccessToken() ?? '' });
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
socket.on('session_state', (payload) => {
|
|
136
|
+
const applied = this.applyState(payload);
|
|
137
|
+
if (applied) {
|
|
138
|
+
const active = this.state?.activeAccountId ?? null;
|
|
139
|
+
if (active && active !== this.host.getCurrentAccountId()) {
|
|
140
|
+
void this.bootstrap().catch((error) => {
|
|
141
|
+
logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
this.socket = socket;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { SessionClient } from './SessionClient.js';
|
|
2
|
+
import { createSessionClientHost } from './sessionClientHost.js';
|
|
3
|
+
/**
|
|
4
|
+
* Wires a `SessionClient` over the given `OxyServices` instance: builds the
|
|
5
|
+
* `SessionClientHost` adapter and passes it through together with a
|
|
6
|
+
* caller-supplied `TokenTransport`.
|
|
7
|
+
*
|
|
8
|
+
* The transport is a required parameter (not constructed here) because it is
|
|
9
|
+
* the one piece of this integration that is NOT platform-agnostic: `services`
|
|
10
|
+
* branches native (shared-keychain sign-in) vs. web (silent sign-in), while
|
|
11
|
+
* `auth-sdk` is web-only. Each consumer builds its own transport and passes
|
|
12
|
+
* it in; this factory only wires the platform-agnostic parts (host + client)
|
|
13
|
+
* so neither consumer re-implements them.
|
|
14
|
+
*
|
|
15
|
+
* The host is returned alongside the client (not just the client) so the
|
|
16
|
+
* caller can call `host.setCurrentAccountId(...)` as the active account
|
|
17
|
+
* changes.
|
|
18
|
+
*/
|
|
19
|
+
export function createSessionClient(oxyServices, transport) {
|
|
20
|
+
const host = createSessionClientHost(oxyServices);
|
|
21
|
+
const client = new SessionClient(host, { transport });
|
|
22
|
+
return { client, host };
|
|
23
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure projection helpers: `DeviceSessionState` (the device-scoped
|
|
3
|
+
* multi-account session-sync state produced by `SessionClient`) -> the
|
|
4
|
+
* shapes consumers (`@oxyhq/services`, `@oxyhq/auth`) render today
|
|
5
|
+
* (`ClientSession[]`, an active session id, an active `User`).
|
|
6
|
+
*
|
|
7
|
+
* No I/O. The caller fetches profiles via
|
|
8
|
+
* `oxyServices.getUsersByIds(accountIdsOf(state))` and builds `usersById`
|
|
9
|
+
* from the result before calling `deviceStateToClientSessions` /
|
|
10
|
+
* `activeUserOf`.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
|
|
14
|
+
*
|
|
15
|
+
* `DeviceSessionState` carries no per-account `expiresAt` / `lastActive` —
|
|
16
|
+
* both are set to `state.updatedAt` (converted to an ISO-8601 string; the
|
|
17
|
+
* wire value is an epoch-ms number) as a provisional value.
|
|
18
|
+
*
|
|
19
|
+
* `usersById` is accepted for signature symmetry with `activeUserOf` even
|
|
20
|
+
* though `ClientSession` only stores `userId` — a session is still
|
|
21
|
+
* projected for an account whose id is absent from `usersById` (no
|
|
22
|
+
* placeholder user is fabricated).
|
|
23
|
+
*/
|
|
24
|
+
export function deviceStateToClientSessions(state, usersById) {
|
|
25
|
+
const provisionalTimestamp = new Date(state.updatedAt).toISOString();
|
|
26
|
+
return state.accounts.map((account) => ({
|
|
27
|
+
sessionId: account.sessionId,
|
|
28
|
+
deviceId: state.deviceId,
|
|
29
|
+
// provisional: expiresAt/lastActive are not carried on DeviceSessionState
|
|
30
|
+
expiresAt: provisionalTimestamp,
|
|
31
|
+
lastActive: provisionalTimestamp,
|
|
32
|
+
userId: account.accountId,
|
|
33
|
+
isCurrent: account.accountId === state.activeAccountId,
|
|
34
|
+
authuser: account.authuser,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The active account's `sessionId`, or `null` when there is no state or no
|
|
39
|
+
* active account is set.
|
|
40
|
+
*/
|
|
41
|
+
export function activeSessionIdOf(state) {
|
|
42
|
+
if (state === null || state.activeAccountId === null) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const activeAccountId = state.activeAccountId;
|
|
46
|
+
const activeAccount = state.accounts.find((account) => account.accountId === activeAccountId);
|
|
47
|
+
return activeAccount?.sessionId ?? null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The active account's `User`, resolved from `usersById`. `null` when there
|
|
51
|
+
* is no state, no active account is set, or the active account id is absent
|
|
52
|
+
* from `usersById`.
|
|
53
|
+
*/
|
|
54
|
+
export function activeUserOf(state, usersById) {
|
|
55
|
+
if (state === null || state.activeAccountId === null) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return usersById.get(state.activeAccountId) ?? null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* All account ids in `state`, suitable for an `oxyServices.getUsersByIds(...)`
|
|
62
|
+
* fetch. `[]` for `null` state.
|
|
63
|
+
*/
|
|
64
|
+
export function accountIdsOf(state) {
|
|
65
|
+
if (state === null) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
return state.accounts.map((account) => account.accountId);
|
|
69
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin `SessionClientHost` adapter over an `OxyServices` instance.
|
|
3
|
+
*
|
|
4
|
+
* `SessionClient` is host-agnostic: it only needs a REST + token surface.
|
|
5
|
+
* `OxyServices` already exposes all of that except `getCurrentAccountId`,
|
|
6
|
+
* which has no direct equivalent — the adapter holds a mutable ref set by
|
|
7
|
+
* the caller (`OxyContext` in `@oxyhq/services`, `WebOxyProvider` in
|
|
8
|
+
* `@oxyhq/auth`) via `setCurrentAccountId`.
|
|
9
|
+
*
|
|
10
|
+
* Shared here (rather than duplicated per consumer) because it is entirely
|
|
11
|
+
* platform-agnostic: every method it calls exists identically on
|
|
12
|
+
* `OxyServices` regardless of host (web, Expo/RN, Node).
|
|
13
|
+
*/
|
|
14
|
+
export function createSessionClientHost(oxyServices) {
|
|
15
|
+
let currentAccountId = null;
|
|
16
|
+
return {
|
|
17
|
+
makeRequest: (method, url, data, options) => oxyServices.makeRequest(method, url, data, options),
|
|
18
|
+
getBaseURL: () => oxyServices.getBaseURL(),
|
|
19
|
+
getAccessToken: () => oxyServices.getAccessToken(),
|
|
20
|
+
onTokensChanged: (listener) => oxyServices.onTokensChanged(listener),
|
|
21
|
+
setTokens: (accessToken) => oxyServices.setTokens(accessToken),
|
|
22
|
+
getCurrentAccountId: () => currentAccountId,
|
|
23
|
+
setCurrentAccountId: (id) => {
|
|
24
|
+
currentAccountId = id;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { logger } from '../utils/loggerUtils.js';
|
|
2
|
+
let cachedFactory = null;
|
|
3
|
+
let loadAttempted = false;
|
|
4
|
+
export async function getSocketIO() {
|
|
5
|
+
if (cachedFactory)
|
|
6
|
+
return cachedFactory;
|
|
7
|
+
if (loadAttempted)
|
|
8
|
+
return null;
|
|
9
|
+
loadAttempted = true;
|
|
10
|
+
try {
|
|
11
|
+
const mod = (await import('socket.io-client'));
|
|
12
|
+
cachedFactory = mod.io ?? mod.default ?? null;
|
|
13
|
+
return cachedFactory;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
logger.warn('[SessionClient] socket.io-client import failed; realtime session sync disabled', { component: 'SessionClient' }, error);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -124,18 +124,18 @@ export function ssoPriorSessionKey(origin) {
|
|
|
124
124
|
* per-tab `sessionStorage` the loop-breaker keys use — it must survive a reload.
|
|
125
125
|
*
|
|
126
126
|
* It exists purely to suppress AUTOMATIC silent restore after a deliberate
|
|
127
|
-
* sign-out: a still-live IdP session (the central `fedcm_session`
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
127
|
+
* sign-out: a still-live IdP session (the central `fedcm_session`) would
|
|
128
|
+
* otherwise let the per-apex `/auth/silent` iframe re-mint a session on the
|
|
129
|
+
* very next cold boot, so a user who pressed "Sign out" gets silently signed
|
|
130
|
+
* back in on reload. With this flag set, that silent cold-boot step is
|
|
131
|
+
* skipped while the Gmail-style returning-account fast-path is otherwise
|
|
132
|
+
* preserved.
|
|
133
133
|
*
|
|
134
134
|
* Lifecycle (mirrors the existing gate machinery — set on a definitive event,
|
|
135
135
|
* cleared on its inverse):
|
|
136
136
|
* - SET on EXPLICIT full sign-out (alongside clearing the prior-session hint
|
|
137
137
|
* and the SSO bounce state).
|
|
138
|
-
* - CLEARED on ANY deliberate sign-in (password,
|
|
138
|
+
* - CLEARED on ANY deliberate sign-in (password, account switch, device
|
|
139
139
|
* claim) so a real sign-in fully re-enables silent restore — there is no
|
|
140
140
|
* "stuck signed out" state.
|
|
141
141
|
*
|
|
@@ -279,8 +279,8 @@ export function guardActive(storage, origin, now = Date.now()) {
|
|
|
279
279
|
* Whether AUTOMATIC silent restore is SUPPRESSED for this origin because the
|
|
280
280
|
* user deliberately signed out (the durable {@link ssoSignedOutKey} flag).
|
|
281
281
|
*
|
|
282
|
-
* When `true`, the silent cold-boot
|
|
283
|
-
* still-live IdP session WITHOUT user intent —
|
|
282
|
+
* When `true`, the silent cold-boot step that can re-mint a session from a
|
|
283
|
+
* still-live IdP session WITHOUT user intent — the per-apex
|
|
284
284
|
* `/auth/silent` iframe — MUST be skipped, so a user who pressed "Sign out" is
|
|
285
285
|
* not silently signed back in on the next reload. Interactive sign-in clears the
|
|
286
286
|
* flag, so this never blocks a deliberate re-sign-in.
|