@oxyhq/core 5.2.0 → 5.3.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 (117) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +32 -42
  3. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  4. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  5. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  6. package/dist/cjs/i18n/locales/en-US.json +1 -0
  7. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  8. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  9. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  10. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  11. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  23. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  24. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  25. package/dist/cjs/index.js +24 -2
  26. package/dist/cjs/mixins/OxyServices.sso.js +36 -0
  27. package/dist/cjs/mixins/OxyServices.user.js +24 -0
  28. package/dist/cjs/server/index.js +13 -1
  29. package/dist/cjs/session/SessionClient.js +142 -0
  30. package/dist/cjs/session/createSessionClient.js +26 -0
  31. package/dist/cjs/session/projectSessionState.js +75 -0
  32. package/dist/cjs/session/sessionClientHost.js +30 -0
  33. package/dist/cjs/session/socketLoader.js +55 -0
  34. package/dist/cjs/utils/cache.js +2 -0
  35. package/dist/cjs/utils/ssoBounce.js +9 -9
  36. package/dist/cjs/utils/ssoEstablish.js +110 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/CrossDomainAuth.js +32 -42
  39. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  40. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  41. package/dist/esm/i18n/locales/de-DE.json +1 -0
  42. package/dist/esm/i18n/locales/en-US.json +1 -0
  43. package/dist/esm/i18n/locales/es-ES.json +1 -0
  44. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  45. package/dist/esm/i18n/locales/it-IT.json +1 -0
  46. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  47. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  48. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  49. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  50. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  51. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  52. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  53. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  54. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  55. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  56. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  57. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  58. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  59. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  60. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  61. package/dist/esm/index.js +14 -0
  62. package/dist/esm/mixins/OxyServices.sso.js +36 -0
  63. package/dist/esm/mixins/OxyServices.user.js +24 -0
  64. package/dist/esm/server/index.js +10 -0
  65. package/dist/esm/session/SessionClient.js +138 -0
  66. package/dist/esm/session/createSessionClient.js +23 -0
  67. package/dist/esm/session/projectSessionState.js +69 -0
  68. package/dist/esm/session/sessionClientHost.js +27 -0
  69. package/dist/esm/session/socketLoader.js +19 -0
  70. package/dist/esm/utils/cache.js +2 -0
  71. package/dist/esm/utils/ssoBounce.js +9 -9
  72. package/dist/esm/utils/ssoEstablish.js +107 -0
  73. package/dist/types/.tsbuildinfo +1 -1
  74. package/dist/types/CrossDomainAuth.d.ts +33 -13
  75. package/dist/types/index.d.ts +7 -0
  76. package/dist/types/mixins/OxyServices.sso.d.ts +24 -0
  77. package/dist/types/mixins/OxyServices.user.d.ts +14 -0
  78. package/dist/types/server/index.d.ts +2 -0
  79. package/dist/types/session/SessionClient.d.ts +55 -0
  80. package/dist/types/session/createSessionClient.d.ts +23 -0
  81. package/dist/types/session/projectSessionState.d.ts +43 -0
  82. package/dist/types/session/sessionClientHost.d.ts +18 -0
  83. package/dist/types/session/socketLoader.d.ts +9 -0
  84. package/dist/types/utils/ssoBounce.d.ts +9 -9
  85. package/dist/types/utils/ssoEstablish.d.ts +85 -0
  86. package/package.json +1 -1
  87. package/src/CrossDomainAuth.ts +33 -44
  88. package/src/__tests__/crossDomainAuth.test.ts +33 -16
  89. package/src/i18n/locales/ar-SA.json +1 -0
  90. package/src/i18n/locales/ca-ES.json +1 -0
  91. package/src/i18n/locales/de-DE.json +1 -0
  92. package/src/i18n/locales/en-US.json +1 -0
  93. package/src/i18n/locales/es-ES.json +1 -0
  94. package/src/i18n/locales/fr-FR.json +1 -0
  95. package/src/i18n/locales/it-IT.json +1 -0
  96. package/src/i18n/locales/ja-JP.json +1 -0
  97. package/src/i18n/locales/ko-KR.json +1 -0
  98. package/src/i18n/locales/pt-PT.json +1 -0
  99. package/src/i18n/locales/zh-CN.json +1 -0
  100. package/src/index.ts +24 -0
  101. package/src/mixins/OxyServices.sso.ts +55 -0
  102. package/src/mixins/OxyServices.user.ts +26 -0
  103. package/src/mixins/__tests__/sso.test.ts +41 -0
  104. package/src/server/index.ts +12 -0
  105. package/src/session/SessionClient.ts +175 -0
  106. package/src/session/__tests__/SessionClient.rest.test.ts +79 -0
  107. package/src/session/__tests__/SessionClient.socket.test.ts +132 -0
  108. package/src/session/__tests__/SessionClient.state.test.ts +64 -0
  109. package/src/session/__tests__/sessionIntegration.test.ts +202 -0
  110. package/src/session/createSessionClient.ts +31 -0
  111. package/src/session/projectSessionState.ts +83 -0
  112. package/src/session/sessionClientHost.ts +32 -0
  113. package/src/session/socketLoader.ts +28 -0
  114. package/src/utils/__tests__/ssoEstablish.test.ts +204 -0
  115. package/src/utils/cache.ts +2 -0
  116. package/src/utils/ssoBounce.ts +9 -9
  117. 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 that automatically
5
- * selects the best authentication method based on browser capabilities:
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
- * 1. FedCM (if supported) - Modern, Google-style browser-native auth
8
- * 2. Redirect (fallback) - Tokenless central SSO full-page redirect
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
@@ -47,18 +55,25 @@ export declare class CrossDomainAuth {
47
55
  private oxyServices;
48
56
  constructor(oxyServices: OxyServices);
49
57
  /**
50
- * Sign in with automatic method selection
58
+ * Sign in with automatic method selection.
51
59
  *
52
- * Tries methods in this order:
53
- * 1. FedCM (if supported and not in private browsing)
54
- * 2. Redirect (always works)
60
+ * Auto mode always uses the full-page redirect (see the class doc comment
61
+ * for why FedCM was removed from this path). Pass `{ method: 'fedcm' }` to
62
+ * opt into FedCM explicitly.
55
63
  *
56
64
  * @param options - Authentication options
57
65
  * @returns Session with user data and access token
58
66
  */
59
67
  signIn(options?: CrossDomainAuthOptions): Promise<SessionLoginResponse | null>;
60
68
  /**
61
- * Automatic sign-in with progressive enhancement
69
+ * Automatic sign-in.
70
+ *
71
+ * Goes straight to the full-page redirect — the sole automatic method.
72
+ * FedCM is deliberately NOT attempted here (see the class doc comment):
73
+ * it is Chrome-only, and its fast/silent failure mode combined with a
74
+ * caller's auth-guard effect re-invoking `signIn()` produced a real
75
+ * production sign-in loop. Use `signIn({ method: 'fedcm' })` to opt in
76
+ * explicitly.
62
77
  *
63
78
  * @private
64
79
  */
@@ -84,8 +99,9 @@ export declare class CrossDomainAuth {
84
99
  /**
85
100
  * Silent sign-in (check for existing session)
86
101
  *
87
- * Tries to automatically sign in without user interaction.
88
- * Works with FedCM and iframe-based silent auth.
102
+ * Tries to automatically sign in without user interaction, via the
103
+ * iframe-based silent auth against the per-apex `/auth/silent` IdP host.
104
+ * FedCM is deliberately NOT attempted here (see the class doc comment).
89
105
  *
90
106
  * @returns Session if user is already signed in, null otherwise
91
107
  */
@@ -104,10 +120,14 @@ export declare class CrossDomainAuth {
104
120
  /**
105
121
  * Get recommended authentication method for current environment
106
122
  *
123
+ * Redirect is the sole recommended automatic method — it works in every
124
+ * browser, unlike FedCM (Chrome-only). Callers that want FedCM must opt in
125
+ * explicitly via `signIn({ method: 'fedcm' })`.
126
+ *
107
127
  * @returns Recommended method name and reason
108
128
  */
109
129
  getRecommendedMethod(): {
110
- method: 'fedcm' | 'redirect';
130
+ method: 'redirect';
111
131
  reason: string;
112
132
  };
113
133
  /**
@@ -93,10 +93,17 @@ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './uti
93
93
  export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
94
94
  export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
95
95
  export { generateSsoState } from './mixins/OxyServices.sso';
96
+ export { establishIdpSessionAfterClaim } from './utils/ssoEstablish';
97
+ export type { SsoEstablishClient, EstablishAfterClaimDeps } from './utils/ssoEstablish';
96
98
  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';
97
99
  export type { SsoBounceGate } from './utils/ssoBounce';
98
100
  export { runColdBoot } from './utils/coldBoot';
99
101
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
102
+ export { SessionClient } from './session/SessionClient';
103
+ export type { TokenTransport, SessionClientHost, SessionClientOptions } from './session/SessionClient';
104
+ export { createSessionClientHost } from './session/sessionClientHost';
105
+ export { createSessionClient } from './session/createSessionClient';
106
+ export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountIdsOf, } from './session/projectSessionState';
100
107
  export { packageInfo } from './constants/version';
101
108
  import { OxyServices } from './OxyServices';
102
109
  export default OxyServices;
@@ -59,6 +59,30 @@ export declare function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Ba
59
59
  * @returns The resolved {@link SessionLoginResponse}.
60
60
  */
61
61
  exchangeSsoCode(code: string, state?: string): Promise<SessionLoginResponse>;
62
+ /**
63
+ * Mint a server-formed `/sso/establish` URL for the caller's OWN session,
64
+ * bound to an approved RP `origin`.
65
+ *
66
+ * Bearer-authenticated (the session id is taken from the caller's own
67
+ * bearer, server-side — never from any argument). The server validates that
68
+ * `origin` is an approved client origin (and matches the request `Origin`),
69
+ * derives the per-apex IdP host (`auth.<apex>`), mints a short-lived HS256
70
+ * establish-token, and returns a fully-formed
71
+ * `https://<auth-host>/sso/establish?et=…&return_to=<origin>/__oxy/sso-callback&state=<state>`.
72
+ *
73
+ * Used AFTER a web device-flow claim to plant the durable first-party
74
+ * `fedcm_session` cookie so a reload can re-mint a token (see
75
+ * {@link establishIdpSessionAfterClaim}). Cache-free (a POST is never
76
+ * cached, but `cache: false` is explicit).
77
+ *
78
+ * @param origin - The RP origin (`window.location.origin`) to establish for.
79
+ * @param state - The CSRF state echoed back in the callback fragment; the
80
+ * caller persists the SAME value under `ssoStateKey(origin)` so the
81
+ * post-bounce `sso-return` step validates it.
82
+ */
83
+ requestSsoEstablishUrl(origin: string, state: string): Promise<{
84
+ establishUrl: string;
85
+ }>;
62
86
  httpService: import("../HttpService").HttpService;
63
87
  cloudURL: string;
64
88
  config: import("../OxyServices.base").OxyConfig;
@@ -327,6 +327,20 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
327
327
  total: number;
328
328
  hasMore: boolean;
329
329
  }>;
330
+ /**
331
+ * Get the authenticated VIEWER's OWN mutual-follow user ids — the accounts the
332
+ * viewer follows that ALSO follow the viewer back (a bidirectional follow
333
+ * edge). The viewer is derived server-side from the SDK's auth token (never a
334
+ * param), so there is no target id to pass.
335
+ *
336
+ * Returns a bounded, lean list of ids meant to SEED a "Mutuals" feed (the
337
+ * consumer hydrates/ranks the posts itself) — distinct from
338
+ * {@link getUserMutuals}, which returns hydrated "followers you know" DTOs
339
+ * about ANOTHER profile. An anonymous caller resolves to an empty array.
340
+ */
341
+ getMutualUserIds(params?: {
342
+ limit?: number;
343
+ }): Promise<string[]>;
330
344
  /**
331
345
  * Get notifications
332
346
  */
@@ -23,3 +23,5 @@ export type { SafeFetchOptions, SafeFetchResult, SsrfCheckFail, SsrfCheckOk, Ssr
23
23
  export { createOxyCors } from './cors';
24
24
  export type { OxyCorsOptions } from './cors';
25
25
  export { verifySecret } from './verifySecret';
26
+ export { registrableApex } from '../utils/fapiAutoDetect';
27
+ export { SSO_CALLBACK_PATH } from '../utils/ssoBounce';
@@ -0,0 +1,55 @@
1
+ import { type DeviceSessionState } from '@oxyhq/contracts';
2
+ import type { MinimalSocket } from './socketLoader';
3
+ export interface TokenTransport {
4
+ /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via FedCM/silent/sso/keychain). Best-effort. */
5
+ ensureActiveToken(state: DeviceSessionState): Promise<void>;
6
+ }
7
+ export interface SessionClientHost {
8
+ makeRequest<T>(method: 'GET' | 'POST', url: string, data?: unknown, options?: {
9
+ cache?: boolean;
10
+ }): Promise<T>;
11
+ getBaseURL(): string;
12
+ getAccessToken(): string | null;
13
+ onTokensChanged(listener: (token: string | null) => void): () => void;
14
+ setTokens(accessToken: string): void;
15
+ getCurrentAccountId(): string | null;
16
+ }
17
+ export interface SessionClientOptions {
18
+ transport?: TokenTransport;
19
+ }
20
+ type StateListener = (state: DeviceSessionState | null) => void;
21
+ export declare class SessionClient {
22
+ protected readonly host: SessionClientHost;
23
+ protected readonly options: SessionClientOptions;
24
+ private state;
25
+ private readonly listeners;
26
+ protected socket: MinimalSocket | null;
27
+ private tokenUnsub;
28
+ private started;
29
+ constructor(host: SessionClientHost, options?: SessionClientOptions);
30
+ getState(): DeviceSessionState | null;
31
+ subscribe(listener: StateListener): () => void;
32
+ protected notify(): void;
33
+ /** Validate + last-writer-wins by revision. Returns true if applied. */
34
+ protected applyState(raw: unknown): boolean;
35
+ /**
36
+ * Validate `{ state, activeToken }`, apply the state, and plant the active token host-side.
37
+ * Token-planting is decoupled from whether `applyState` advanced the revision: a socket push
38
+ * followed by this same `GET /state` fetch returns the SAME revision (applyState no-ops), but
39
+ * the token still needs to be planted. The account-match guard rejects a stale response for an
40
+ * account that is no longer active.
41
+ */
42
+ private applySync;
43
+ bootstrap(): Promise<void>;
44
+ switchAccount(accountId: string): Promise<void>;
45
+ signOut(target: {
46
+ accountId: string;
47
+ } | {
48
+ all: true;
49
+ }): Promise<void>;
50
+ addCurrentAccount(): Promise<void>;
51
+ start(): Promise<void>;
52
+ stop(): void;
53
+ private connectSocket;
54
+ }
55
+ export {};
@@ -0,0 +1,23 @@
1
+ import type { OxyServices } from '../OxyServices';
2
+ import { SessionClient, type TokenTransport } from './SessionClient';
3
+ import { createSessionClientHost } from './sessionClientHost';
4
+ /**
5
+ * Wires a `SessionClient` over the given `OxyServices` instance: builds the
6
+ * `SessionClientHost` adapter and passes it through together with a
7
+ * caller-supplied `TokenTransport`.
8
+ *
9
+ * The transport is a required parameter (not constructed here) because it is
10
+ * the one piece of this integration that is NOT platform-agnostic: `services`
11
+ * branches native (shared-keychain sign-in) vs. web (silent sign-in), while
12
+ * `auth-sdk` is web-only. Each consumer builds its own transport and passes
13
+ * it in; this factory only wires the platform-agnostic parts (host + client)
14
+ * so neither consumer re-implements them.
15
+ *
16
+ * The host is returned alongside the client (not just the client) so the
17
+ * caller can call `host.setCurrentAccountId(...)` as the active account
18
+ * changes.
19
+ */
20
+ export declare function createSessionClient(oxyServices: OxyServices, transport: TokenTransport): {
21
+ client: SessionClient;
22
+ host: ReturnType<typeof createSessionClientHost>;
23
+ };
@@ -0,0 +1,43 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import type { ClientSession } from '../models/session';
3
+ import type { User } from '../models/interfaces';
4
+ /**
5
+ * Pure projection helpers: `DeviceSessionState` (the device-scoped
6
+ * multi-account session-sync state produced by `SessionClient`) -> the
7
+ * shapes consumers (`@oxyhq/services`, `@oxyhq/auth`) render today
8
+ * (`ClientSession[]`, an active session id, an active `User`).
9
+ *
10
+ * No I/O. The caller fetches profiles via
11
+ * `oxyServices.getUsersByIds(accountIdsOf(state))` and builds `usersById`
12
+ * from the result before calling `deviceStateToClientSessions` /
13
+ * `activeUserOf`.
14
+ */
15
+ /**
16
+ * Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
17
+ *
18
+ * `DeviceSessionState` carries no per-account `expiresAt` / `lastActive` —
19
+ * both are set to `state.updatedAt` (converted to an ISO-8601 string; the
20
+ * wire value is an epoch-ms number) as a provisional value.
21
+ *
22
+ * `usersById` is accepted for signature symmetry with `activeUserOf` even
23
+ * though `ClientSession` only stores `userId` — a session is still
24
+ * projected for an account whose id is absent from `usersById` (no
25
+ * placeholder user is fabricated).
26
+ */
27
+ export declare function deviceStateToClientSessions(state: DeviceSessionState, usersById: Map<string, User>): ClientSession[];
28
+ /**
29
+ * The active account's `sessionId`, or `null` when there is no state or no
30
+ * active account is set.
31
+ */
32
+ export declare function activeSessionIdOf(state: DeviceSessionState | null): string | null;
33
+ /**
34
+ * The active account's `User`, resolved from `usersById`. `null` when there
35
+ * is no state, no active account is set, or the active account id is absent
36
+ * from `usersById`.
37
+ */
38
+ export declare function activeUserOf(state: DeviceSessionState | null, usersById: Map<string, User>): User | null;
39
+ /**
40
+ * All account ids in `state`, suitable for an `oxyServices.getUsersByIds(...)`
41
+ * fetch. `[]` for `null` state.
42
+ */
43
+ export declare function accountIdsOf(state: DeviceSessionState | null): string[];
@@ -0,0 +1,18 @@
1
+ import type { OxyServices } from '../OxyServices';
2
+ import type { SessionClientHost } from './SessionClient';
3
+ /**
4
+ * Thin `SessionClientHost` adapter over an `OxyServices` instance.
5
+ *
6
+ * `SessionClient` is host-agnostic: it only needs a REST + token surface.
7
+ * `OxyServices` already exposes all of that except `getCurrentAccountId`,
8
+ * which has no direct equivalent — the adapter holds a mutable ref set by
9
+ * the caller (`OxyContext` in `@oxyhq/services`, `WebOxyProvider` in
10
+ * `@oxyhq/auth`) via `setCurrentAccountId`.
11
+ *
12
+ * Shared here (rather than duplicated per consumer) because it is entirely
13
+ * platform-agnostic: every method it calls exists identically on
14
+ * `OxyServices` regardless of host (web, Expo/RN, Node).
15
+ */
16
+ export declare function createSessionClientHost(oxyServices: OxyServices): SessionClientHost & {
17
+ setCurrentAccountId(id: string | null): void;
18
+ };
@@ -0,0 +1,9 @@
1
+ export interface MinimalSocket {
2
+ connected: boolean;
3
+ on(event: string, handler: (...args: unknown[]) => void): void;
4
+ off(event: string, handler?: (...args: unknown[]) => void): void;
5
+ connect(): void;
6
+ disconnect(): void;
7
+ }
8
+ export type SocketIOFactory = (uri: string, opts?: Record<string, unknown>) => MinimalSocket;
9
+ export declare function getSocketIO(): Promise<SocketIOFactory | null>;
@@ -103,18 +103,18 @@ export declare function ssoPriorSessionKey(origin: string): string;
103
103
  * per-tab `sessionStorage` the loop-breaker keys use — it must survive a reload.
104
104
  *
105
105
  * It exists purely to suppress AUTOMATIC silent restore after a deliberate
106
- * sign-out: a still-live IdP session (the central `fedcm_session` / the FedCM
107
- * credential association) would otherwise let `fedcm-silent` / the per-apex
108
- * `/auth/silent` iframe re-mint a session on the very next cold boot, so a user
109
- * who pressed "Sign out" gets silently signed back in on reload. With this flag
110
- * set, those silent cold-boot steps are skipped while the Gmail-style
111
- * returning-account fast-path is otherwise preserved.
106
+ * sign-out: a still-live IdP session (the central `fedcm_session`) would
107
+ * otherwise let the per-apex `/auth/silent` iframe re-mint a session on the
108
+ * very next cold boot, so a user who pressed "Sign out" gets silently signed
109
+ * back in on reload. With this flag set, that silent cold-boot step is
110
+ * skipped while the Gmail-style returning-account fast-path is otherwise
111
+ * preserved.
112
112
  *
113
113
  * Lifecycle (mirrors the existing gate machinery — set on a definitive event,
114
114
  * cleared on its inverse):
115
115
  * - SET on EXPLICIT full sign-out (alongside clearing the prior-session hint
116
116
  * and the SSO bounce state).
117
- * - CLEARED on ANY deliberate sign-in (password, FedCM, account switch, device
117
+ * - CLEARED on ANY deliberate sign-in (password, account switch, device
118
118
  * claim) so a real sign-in fully re-enables silent restore — there is no
119
119
  * "stuck signed out" state.
120
120
  *
@@ -206,8 +206,8 @@ export declare function guardActive(storage: Pick<Storage, 'getItem'>, origin: s
206
206
  * Whether AUTOMATIC silent restore is SUPPRESSED for this origin because the
207
207
  * user deliberately signed out (the durable {@link ssoSignedOutKey} flag).
208
208
  *
209
- * When `true`, the silent cold-boot steps that can re-mint a session from a
210
- * still-live IdP session WITHOUT user intent — `fedcm-silent` and the per-apex
209
+ * When `true`, the silent cold-boot step that can re-mint a session from a
210
+ * still-live IdP session WITHOUT user intent — the per-apex
211
211
  * `/auth/silent` iframe — MUST be skipped, so a user who pressed "Sign out" is
212
212
  * not silently signed back in on the next reload. Interactive sign-in clears the
213
213
  * flag, so this never blocks a deliberate re-sign-in.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Post-claim durable-session establish hop (web device-flow / "Sign in with
3
+ * Oxy" QR).
4
+ *
5
+ * A WEB device-flow claim (`claimSessionByToken`) plants only IN-MEMORY tokens:
6
+ * unlike a redirect/FedCM/silent sign-in, it never causes the IdP to plant a
7
+ * `fedcm_session` cookie. So a reload has nothing to restore from — the
8
+ * silent-iframe and `/sso` paths find no IdP session and the session is lost.
9
+ *
10
+ * This primitive closes that gap. AFTER the claim has committed and the session
11
+ * has been durably persisted, it performs ONE top-level establish hop through
12
+ * the RP's own per-apex IdP host (`auth.<rp-apex>`), reusing the EXISTING
13
+ * `/sso/establish` endpoint: the server mints a short-lived, host+audience-bound
14
+ * establish-token and returns a fully-formed establish URL; navigating to it
15
+ * plants the durable first-party `fedcm_session` cookie and bounces back to the
16
+ * RP callback with an opaque code the standard `sso-return` cold-boot step
17
+ * exchanges.
18
+ *
19
+ * It reuses the SAME per-origin `sessionStorage` bounce contract
20
+ * (`ssoStateKey` / `ssoGuardKey` / `ssoDestKey`) that {@link buildSsoBounceUrl}
21
+ * primes for the terminal `/sso` bounce, so the post-bounce `sso-return` step
22
+ * (`consumeSsoReturn`) validates the CSRF `state`, exchanges the code, and
23
+ * restores the user's real destination with no extra wiring.
24
+ *
25
+ * Contract:
26
+ * - WEB only — off-web / native it is a no-op returning `false`.
27
+ * - NEVER fires while sitting on the central IdP origin (that would loop the
28
+ * IdP against itself).
29
+ * - Bounce state is persisted ONLY after the establish-URL request succeeds, so
30
+ * a failed request leaves no stale state behind.
31
+ * - SINGLE attempt: the caller invokes this exactly once per successful claim.
32
+ * On ANY failure it does NOT navigate and returns `false`, leaving the
33
+ * committed in-memory session exactly as-is (the user is no worse off than
34
+ * before this hop existed).
35
+ * - Total: never throws. Failures are reported via {@link deps.onError} only.
36
+ */
37
+ /**
38
+ * The minimal SDK surface this hop needs: mint a server-formed establish URL
39
+ * bound to the caller's own session for an approved RP origin. Structural so the
40
+ * primitive is unit-testable with a stub and never imports the full client.
41
+ */
42
+ export interface SsoEstablishClient {
43
+ requestSsoEstablishUrl(origin: string, state: string): Promise<{
44
+ establishUrl: string;
45
+ }>;
46
+ }
47
+ /**
48
+ * Injectable web seams for {@link establishIdpSessionAfterClaim}. Every seam is
49
+ * overridable so the primitive is fully unit-testable with fakes and so native
50
+ * callers rely on the defaults (which resolve to `window.*` only when a browser
51
+ * is present). Defaults are evaluated lazily inside the function so importing
52
+ * this module never touches `window`.
53
+ */
54
+ export interface EstablishAfterClaimDeps {
55
+ /** Per-tab SSO bounce store. Default: `window.sessionStorage`. */
56
+ storage?: Pick<Storage, 'getItem' | 'setItem'>;
57
+ /** The current location. Default: `window.location`. */
58
+ location?: Pick<Location, 'origin' | 'href'>;
59
+ /** Top-level navigation seam. Default: {@link ssoNavigate} (`location.assign`). */
60
+ navigate?: (url: string) => void;
61
+ /**
62
+ * Whether the current environment is a web browser with usable
63
+ * `sessionStorage`. Default: `typeof window !== 'undefined' && typeof
64
+ * window.sessionStorage !== 'undefined'`.
65
+ */
66
+ isWeb?: () => boolean;
67
+ /** CSRF state generator. Default: {@link generateSsoState}. */
68
+ generateState?: () => string;
69
+ /** Epoch-ms clock for the bounce guard. Default: `Date.now`. */
70
+ now?: () => number;
71
+ /**
72
+ * Optional debug hook invoked with the thrown error when the establish
73
+ * request (or state persistence) fails. NEVER rethrown. Default: no-op.
74
+ */
75
+ onError?: (error: unknown) => void;
76
+ }
77
+ /**
78
+ * Perform the post-claim establish hop. Returns `true` when a navigation to the
79
+ * establish URL was initiated (the document is being torn down and replaced by
80
+ * the IdP), `false` on every no-op / failure path.
81
+ *
82
+ * @param client - The exchange surface (`oxyServices.requestSsoEstablishUrl`).
83
+ * @param deps - Injectable web seams; see {@link EstablishAfterClaimDeps}.
84
+ */
85
+ export declare function establishIdpSessionAfterClaim(client: SsoEstablishClient, deps?: EstablishAfterClaimDeps): Promise<boolean>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -1,11 +1,19 @@
1
1
  /**
2
2
  * Cross-Domain Authentication Helper
3
3
  *
4
- * Provides a simplified API for cross-domain SSO authentication that automatically
5
- * selects the best authentication method based on browser capabilities:
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
- * 1. FedCM (if supported) - Modern, Google-style browser-native auth
8
- * 2. Redirect (fallback) - Tokenless central SSO full-page redirect
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
@@ -54,11 +62,11 @@ export class CrossDomainAuth {
54
62
  constructor(private oxyServices: OxyServices) {}
55
63
 
56
64
  /**
57
- * Sign in with automatic method selection
65
+ * Sign in with automatic method selection.
58
66
  *
59
- * Tries methods in this order:
60
- * 1. FedCM (if supported and not in private browsing)
61
- * 2. Redirect (always works)
67
+ * Auto mode always uses the full-page redirect (see the class doc comment
68
+ * for why FedCM was removed from this path). Pass `{ method: 'fedcm' }` to
69
+ * opt into FedCM explicitly.
62
70
  *
63
71
  * @param options - Authentication options
64
72
  * @returns Session with user data and access token
@@ -80,22 +88,18 @@ export class CrossDomainAuth {
80
88
  }
81
89
 
82
90
  /**
83
- * Automatic sign-in with progressive enhancement
91
+ * Automatic sign-in.
92
+ *
93
+ * Goes straight to the full-page redirect — the sole automatic method.
94
+ * FedCM is deliberately NOT attempted here (see the class doc comment):
95
+ * it is Chrome-only, and its fast/silent failure mode combined with a
96
+ * caller's auth-guard effect re-invoking `signIn()` produced a real
97
+ * production sign-in loop. Use `signIn({ method: 'fedcm' })` to opt in
98
+ * explicitly.
84
99
  *
85
100
  * @private
86
101
  */
87
102
  private async autoSignIn(options: CrossDomainAuthOptions): Promise<SessionLoginResponse | null> {
88
- // 1. Try FedCM first (best UX, most modern)
89
- if (this.isFedCMSupported()) {
90
- try {
91
- options.onMethodSelected?.('fedcm');
92
- return await this.signInWithFedCM(options);
93
- } catch (error) {
94
- logger.warn('FedCM failed, falling back to redirect', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
95
- }
96
- }
97
-
98
- // 2. Fallback to redirect (always works)
99
103
  options.onMethodSelected?.('redirect');
100
104
  this.signInWithRedirect(options);
101
105
  return null;
@@ -136,25 +140,13 @@ export class CrossDomainAuth {
136
140
  /**
137
141
  * Silent sign-in (check for existing session)
138
142
  *
139
- * Tries to automatically sign in without user interaction.
140
- * Works with FedCM and iframe-based silent auth.
143
+ * Tries to automatically sign in without user interaction, via the
144
+ * iframe-based silent auth against the per-apex `/auth/silent` IdP host.
145
+ * FedCM is deliberately NOT attempted here (see the class doc comment).
141
146
  *
142
147
  * @returns Session if user is already signed in, null otherwise
143
148
  */
144
149
  async silentSignIn(): Promise<SessionLoginResponse | null> {
145
- // Try FedCM silent sign-in first (if supported)
146
- if (this.isFedCMSupported()) {
147
- try {
148
- const session = await this.oxyServices.silentSignInWithFedCM();
149
- if (session) {
150
- return session;
151
- }
152
- } catch (error) {
153
- logger.debug('FedCM silent sign-in did not resolve', { component: 'CrossDomainAuth', method: 'silentSignIn' }, error);
154
- }
155
- }
156
-
157
- // Fallback to iframe-based silent auth
158
150
  try {
159
151
  return await this.oxyServices.silentSignIn();
160
152
  } catch (error) {
@@ -185,16 +177,13 @@ export class CrossDomainAuth {
185
177
  /**
186
178
  * Get recommended authentication method for current environment
187
179
  *
180
+ * Redirect is the sole recommended automatic method — it works in every
181
+ * browser, unlike FedCM (Chrome-only). Callers that want FedCM must opt in
182
+ * explicitly via `signIn({ method: 'fedcm' })`.
183
+ *
188
184
  * @returns Recommended method name and reason
189
185
  */
190
- getRecommendedMethod(): { method: 'fedcm' | 'redirect'; reason: string } {
191
- if (this.isFedCMSupported()) {
192
- return {
193
- method: 'fedcm',
194
- reason: 'FedCM is supported - provides best UX with browser-native auth',
195
- };
196
- }
197
-
186
+ getRecommendedMethod(): { method: 'redirect'; reason: string } {
198
187
  if (typeof window !== 'undefined') {
199
188
  return {
200
189
  method: 'redirect',