@oxyhq/core 5.5.0 → 6.0.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 (181) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +6 -3
  3. package/dist/cjs/OxyServices.base.js +7 -102
  4. package/dist/cjs/boot/coldBootV2.js +350 -0
  5. package/dist/cjs/boot/deviceBootReturn.js +152 -0
  6. package/dist/cjs/crypto/keyManager.js +95 -0
  7. package/dist/cjs/i18n/locales/en-US.json +13 -1
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -1
  9. package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
  11. package/dist/cjs/index.js +47 -44
  12. package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
  15. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  16. package/dist/cjs/mixins/index.js +13 -17
  17. package/dist/cjs/session/SessionClient.js +40 -1
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/refresh.js +264 -0
  20. package/dist/cjs/utils/accountUtils.js +1 -55
  21. package/dist/cjs/utils/authWebUrl.js +6 -15
  22. package/dist/cjs/utils/fapiAutoDetect.js +16 -64
  23. package/dist/cjs/utils/platform.js +19 -0
  24. package/dist/cjs/utils/ssoBounce.js +15 -362
  25. package/dist/cjs/utils/validationUtils.js +57 -0
  26. package/dist/esm/.tsbuildinfo +1 -1
  27. package/dist/esm/HttpService.js +6 -3
  28. package/dist/esm/OxyServices.base.js +7 -102
  29. package/dist/esm/boot/coldBootV2.js +344 -0
  30. package/dist/esm/boot/deviceBootReturn.js +146 -0
  31. package/dist/esm/crypto/keyManager.js +95 -0
  32. package/dist/esm/i18n/locales/en-US.json +13 -1
  33. package/dist/esm/i18n/locales/es-ES.json +13 -1
  34. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  35. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  36. package/dist/esm/index.js +28 -18
  37. package/dist/esm/mixins/OxyServices.accounts.js +6 -13
  38. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  39. package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
  40. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  41. package/dist/esm/mixins/index.js +13 -17
  42. package/dist/esm/session/SessionClient.js +40 -1
  43. package/dist/esm/session/authStateStore.js +278 -0
  44. package/dist/esm/session/refresh.js +257 -0
  45. package/dist/esm/utils/accountUtils.js +0 -53
  46. package/dist/esm/utils/authWebUrl.js +6 -14
  47. package/dist/esm/utils/fapiAutoDetect.js +16 -63
  48. package/dist/esm/utils/platform.js +18 -0
  49. package/dist/esm/utils/ssoBounce.js +14 -345
  50. package/dist/esm/utils/validationUtils.js +56 -0
  51. package/dist/types/.tsbuildinfo +1 -1
  52. package/dist/types/HttpService.d.ts +13 -0
  53. package/dist/types/OxyServices.base.d.ts +0 -52
  54. package/dist/types/OxyServices.d.ts +0 -25
  55. package/dist/types/boot/coldBootV2.d.ts +76 -0
  56. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  57. package/dist/types/crypto/keyManager.d.ts +21 -0
  58. package/dist/types/index.d.ts +15 -21
  59. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
  60. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  61. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  64. package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
  65. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  66. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  67. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  69. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  71. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  83. package/dist/types/mixins/index.d.ts +6 -9
  84. package/dist/types/models/interfaces.d.ts +0 -67
  85. package/dist/types/session/SessionClient.d.ts +25 -0
  86. package/dist/types/session/authStateStore.d.ts +119 -0
  87. package/dist/types/session/refresh.d.ts +93 -0
  88. package/dist/types/utils/accountUtils.d.ts +0 -14
  89. package/dist/types/utils/authWebUrl.d.ts +6 -12
  90. package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
  91. package/dist/types/utils/platform.d.ts +14 -0
  92. package/dist/types/utils/ssoBounce.d.ts +14 -280
  93. package/dist/types/utils/validationUtils.d.ts +15 -0
  94. package/package.json +2 -2
  95. package/src/HttpService.ts +19 -3
  96. package/src/OxyServices.base.ts +7 -112
  97. package/src/OxyServices.ts +0 -38
  98. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  99. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  100. package/src/boot/coldBootV2.ts +426 -0
  101. package/src/boot/deviceBootReturn.ts +195 -0
  102. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  103. package/src/crypto/keyManager.ts +101 -0
  104. package/src/i18n/locales/en-US.json +13 -1
  105. package/src/i18n/locales/es-ES.json +13 -1
  106. package/src/index.ts +74 -65
  107. package/src/mixins/OxyServices.accounts.ts +6 -13
  108. package/src/mixins/OxyServices.auth.ts +78 -253
  109. package/src/mixins/OxyServices.authorizedApps.ts +75 -0
  110. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  111. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  112. package/src/mixins/__tests__/accounts.test.ts +17 -44
  113. package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
  114. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  115. package/src/mixins/index.ts +16 -22
  116. package/src/models/interfaces.ts +0 -79
  117. package/src/session/SessionClient.ts +52 -1
  118. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  119. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  120. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  121. package/src/session/__tests__/authStateStore.test.ts +209 -0
  122. package/src/session/__tests__/refresh.test.ts +256 -0
  123. package/src/session/authStateStore.ts +335 -0
  124. package/src/session/refresh.ts +334 -0
  125. package/src/utils/__tests__/authWebUrl.test.ts +5 -29
  126. package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
  127. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  128. package/src/utils/accountUtils.ts +0 -62
  129. package/src/utils/authWebUrl.ts +6 -15
  130. package/src/utils/fapiAutoDetect.ts +16 -60
  131. package/src/utils/platform.ts +21 -0
  132. package/src/utils/ssoBounce.ts +14 -393
  133. package/src/utils/validationUtils.ts +62 -0
  134. package/dist/cjs/AuthManager.js +0 -1110
  135. package/dist/cjs/AuthManagerTypes.js +0 -13
  136. package/dist/cjs/CrossDomainAuth.js +0 -206
  137. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  138. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  139. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  140. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  141. package/dist/cjs/utils/ssoEstablish.js +0 -110
  142. package/dist/cjs/utils/ssoReturn.js +0 -275
  143. package/dist/esm/AuthManager.js +0 -1105
  144. package/dist/esm/AuthManagerTypes.js +0 -12
  145. package/dist/esm/CrossDomainAuth.js +0 -201
  146. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  147. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  148. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  149. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  150. package/dist/esm/utils/ssoEstablish.js +0 -107
  151. package/dist/esm/utils/ssoReturn.js +0 -271
  152. package/dist/types/AuthManager.d.ts +0 -380
  153. package/dist/types/AuthManagerTypes.d.ts +0 -81
  154. package/dist/types/CrossDomainAuth.d.ts +0 -164
  155. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  156. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  157. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  158. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  159. package/dist/types/utils/ssoReturn.d.ts +0 -156
  160. package/src/AuthManager.ts +0 -1269
  161. package/src/AuthManagerTypes.ts +0 -86
  162. package/src/CrossDomainAuth.ts +0 -243
  163. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  164. package/src/__tests__/authManager.security.test.ts +0 -377
  165. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  166. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  167. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  168. package/src/mixins/OxyServices.redirect.ts +0 -122
  169. package/src/mixins/OxyServices.silent.ts +0 -272
  170. package/src/mixins/OxyServices.sso.ts +0 -261
  171. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  172. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  173. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  174. package/src/mixins/__tests__/silent.test.ts +0 -102
  175. package/src/mixins/__tests__/sso.test.ts +0 -228
  176. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  177. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  178. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  179. package/src/utils/__tests__/ssoReturn.test.ts +0 -276
  180. package/src/utils/ssoEstablish.ts +0 -174
  181. package/src/utils/ssoReturn.ts +0 -389
@@ -1,174 +0,0 @@
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
- import {
39
- ssoStateKey,
40
- ssoGuardKey,
41
- ssoDestKey,
42
- ssoNavigate,
43
- isCentralIdPOrigin,
44
- } from './ssoBounce';
45
- import { generateSsoState } from '../mixins/OxyServices.sso';
46
-
47
- /**
48
- * The minimal SDK surface this hop needs: mint a server-formed establish URL
49
- * bound to the caller's own session for an approved RP origin. Structural so the
50
- * primitive is unit-testable with a stub and never imports the full client.
51
- */
52
- export interface SsoEstablishClient {
53
- requestSsoEstablishUrl(
54
- origin: string,
55
- state: string,
56
- ): Promise<{ establishUrl: string }>;
57
- }
58
-
59
- /**
60
- * Injectable web seams for {@link establishIdpSessionAfterClaim}. Every seam is
61
- * overridable so the primitive is fully unit-testable with fakes and so native
62
- * callers rely on the defaults (which resolve to `window.*` only when a browser
63
- * is present). Defaults are evaluated lazily inside the function so importing
64
- * this module never touches `window`.
65
- */
66
- export interface EstablishAfterClaimDeps {
67
- /** Per-tab SSO bounce store. Default: `window.sessionStorage`. */
68
- storage?: Pick<Storage, 'getItem' | 'setItem'>;
69
- /** The current location. Default: `window.location`. */
70
- location?: Pick<Location, 'origin' | 'href'>;
71
- /** Top-level navigation seam. Default: {@link ssoNavigate} (`location.assign`). */
72
- navigate?: (url: string) => void;
73
- /**
74
- * Whether the current environment is a web browser with usable
75
- * `sessionStorage`. Default: `typeof window !== 'undefined' && typeof
76
- * window.sessionStorage !== 'undefined'`.
77
- */
78
- isWeb?: () => boolean;
79
- /** CSRF state generator. Default: {@link generateSsoState}. */
80
- generateState?: () => string;
81
- /** Epoch-ms clock for the bounce guard. Default: `Date.now`. */
82
- now?: () => number;
83
- /**
84
- * Optional debug hook invoked with the thrown error when the establish
85
- * request (or state persistence) fails. NEVER rethrown. Default: no-op.
86
- */
87
- onError?: (error: unknown) => void;
88
- }
89
-
90
- /**
91
- * Perform the post-claim establish hop. Returns `true` when a navigation to the
92
- * establish URL was initiated (the document is being torn down and replaced by
93
- * the IdP), `false` on every no-op / failure path.
94
- *
95
- * @param client - The exchange surface (`oxyServices.requestSsoEstablishUrl`).
96
- * @param deps - Injectable web seams; see {@link EstablishAfterClaimDeps}.
97
- */
98
- export async function establishIdpSessionAfterClaim(
99
- client: SsoEstablishClient,
100
- deps: EstablishAfterClaimDeps = {},
101
- ): Promise<boolean> {
102
- const isWeb =
103
- deps.isWeb ??
104
- (() =>
105
- typeof window !== 'undefined' &&
106
- typeof window.sessionStorage !== 'undefined');
107
-
108
- if (!isWeb()) {
109
- return false;
110
- }
111
-
112
- const storage = deps.storage ?? window.sessionStorage;
113
- const location = deps.location ?? window.location;
114
- const navigate = deps.navigate ?? ssoNavigate;
115
- const generateState = deps.generateState ?? generateSsoState;
116
- const now = deps.now ?? (() => Date.now());
117
-
118
- const origin = location.origin;
119
-
120
- // Never establish while sitting on the central IdP itself — that would bounce
121
- // the IdP against itself. (The device-flow claim can only happen on an RP.)
122
- if (isCentralIdPOrigin(origin)) {
123
- return false;
124
- }
125
-
126
- const state = generateState();
127
-
128
- let establishUrl: string;
129
- try {
130
- const result = await client.requestSsoEstablishUrl(origin, state);
131
- if (
132
- !result ||
133
- typeof result.establishUrl !== 'string' ||
134
- result.establishUrl.length === 0
135
- ) {
136
- return false;
137
- }
138
- establishUrl = result.establishUrl;
139
-
140
- // Defense-in-depth before a TOP-LEVEL navigation: the establish URL is
141
- // server-formed, but never follow anything that is not a well-formed https
142
- // `/sso/establish` on an `auth.` host. A malformed or unexpected URL aborts
143
- // silently (no navigation, no state persisted) — same total/no-throw
144
- // contract — rather than navigating the document somewhere unintended.
145
- let parsedEstablishUrl: URL;
146
- try {
147
- parsedEstablishUrl = new URL(establishUrl);
148
- } catch {
149
- return false;
150
- }
151
- if (
152
- parsedEstablishUrl.protocol !== 'https:' ||
153
- parsedEstablishUrl.pathname !== '/sso/establish' ||
154
- !parsedEstablishUrl.hostname.toLowerCase().startsWith('auth.')
155
- ) {
156
- return false;
157
- }
158
-
159
- // Persist the bounce state ONLY now that the request has succeeded and the
160
- // URL is validated — so a failed/rejected request never leaves stale state
161
- // behind. This is the exact contract the terminal `/sso` bounce primes via
162
- // `buildSsoBounceUrl`, so the post-bounce `sso-return` step (`consumeSsoReturn`)
163
- // validates `state`, exchanges the returned code, and restores the dest.
164
- storage.setItem(ssoStateKey(origin), state);
165
- storage.setItem(ssoGuardKey(origin), String(now()));
166
- storage.setItem(ssoDestKey(origin), location.href);
167
- } catch (error) {
168
- deps.onError?.(error);
169
- return false;
170
- }
171
-
172
- navigate(establishUrl);
173
- return true;
174
- }
@@ -1,389 +0,0 @@
1
- /**
2
- * Parse the SSO return fragment delivered by the central IdP.
3
- *
4
- * After a top-level redirect bounce to `auth.oxy.so/sso` (prompt=none), the
5
- * central IdP returns the Relying Party to its `redirect_uri` with the result
6
- * encoded in the URL fragment (the `#…` part). The fragment is used — not a
7
- * query string — so the opaque single-use code never reaches a server access
8
- * log, a `Referer` header, or browser history in a recoverable form.
9
- *
10
- * Three outcomes are possible:
11
- * - `#oxy_sso=ok&code=<opaque>&state=<state>` — the IdP had a session; the RP
12
- * exchanges `code` (via `oxy.exchangeSsoCode`) for the real session. NO
13
- * token/JWT ever appears in the URL — only the opaque code.
14
- * - `#oxy_sso=none&state=<state>` — the IdP had no session (prompt=none, user
15
- * not signed in centrally). The RP shows its own signed-out UI.
16
- * - `#oxy_sso=error&state=<state>` — the bounce failed. The RP recovers.
17
- *
18
- * This parser is pure and defensive: it never throws, and `kind` is strictly
19
- * one of `'ok' | 'none' | 'error'`. It returns `null` when the fragment is not
20
- * an oxy_sso fragment at all (i.e. `oxy_sso` is absent or an unrecognised
21
- * value), so the caller can ignore unrelated fragments without special-casing.
22
- */
23
-
24
- import type { SessionLoginResponse } from '../models/session';
25
- import {
26
- SSO_CALLBACK_PATH,
27
- ssoStateKey,
28
- ssoGuardKey,
29
- ssoDestKey,
30
- ssoNoSessionKey,
31
- ssoAttemptedKey,
32
- ssoCallbackBootstrapKey,
33
- } from './ssoBounce';
34
-
35
- /**
36
- * The recognised outcomes of an SSO bounce.
37
- */
38
- export type SsoReturnKind = 'ok' | 'none' | 'error';
39
-
40
- /**
41
- * The parsed result of an SSO return fragment.
42
- *
43
- * `code` is present only for `kind: 'ok'`. `state` echoes the CSRF state the RP
44
- * generated for the bounce (when the IdP round-tripped it). `reason` is present
45
- * only for a NON-`ok` outcome when the IdP supplied one.
46
- */
47
- export interface SsoReturnResult {
48
- kind: SsoReturnKind;
49
- code?: string;
50
- state?: string;
51
- /**
52
- * Machine-readable reason accompanying a NON-`ok` outcome, when the central
53
- * IdP supplies one (e.g. `no_cookie` | `stale_session` | `no_grant` |
54
- * `no_grant_establish` on a `none` bounce). Purely informational: it lets a
55
- * Relying Party surface WHY a silent probe returned no session (e.g. to brand
56
- * a "sign in" screen) without re-deriving it. Absent on `ok`, and whenever the
57
- * IdP did not include a `reason` param.
58
- */
59
- reason?: string;
60
- }
61
-
62
- const VALID_KINDS: ReadonlySet<string> = new Set<SsoReturnKind>(['ok', 'none', 'error']);
63
-
64
- /**
65
- * Parse an SSO return fragment.
66
- *
67
- * @param hash - The URL fragment, with or without the leading `#`
68
- * (e.g. `location.hash`). May be `undefined`/empty.
69
- * @returns The parsed result when `hash` is a recognised oxy_sso fragment,
70
- * otherwise `null`. Never throws.
71
- */
72
- export function parseSsoReturnFragment(hash: string | undefined | null): SsoReturnResult | null {
73
- if (typeof hash !== 'string' || hash.length === 0) {
74
- return null;
75
- }
76
-
77
- // Strip a single leading '#'. A bare '#' (empty fragment) yields no params.
78
- const raw = hash.startsWith('#') ? hash.slice(1) : hash;
79
- if (raw.length === 0) {
80
- return null;
81
- }
82
-
83
- let params: URLSearchParams;
84
- try {
85
- params = new URLSearchParams(raw);
86
- } catch {
87
- // URLSearchParams does not throw for malformed input in practice, but guard
88
- // against any environment/polyfill that might so this stays total.
89
- return null;
90
- }
91
-
92
- const kind = params.get('oxy_sso');
93
- if (kind === null || !VALID_KINDS.has(kind)) {
94
- // Not an oxy_sso fragment (absent or unrecognised value) — ignore it.
95
- return null;
96
- }
97
-
98
- const result: SsoReturnResult = { kind: kind as SsoReturnKind };
99
-
100
- const state = params.get('state');
101
- if (state !== null && state.length > 0) {
102
- result.state = state;
103
- }
104
-
105
- // The opaque code is only meaningful on success; ignore any stray `code` on
106
- // none/error so callers never attempt an exchange for a non-ok outcome.
107
- if (result.kind === 'ok') {
108
- const code = params.get('code');
109
- if (code !== null && code.length > 0) {
110
- result.code = code;
111
- }
112
- } else {
113
- // A machine-readable reason accompanies a NON-`ok` outcome when the IdP
114
- // supplies one. Success carries no reason, so it is only read here.
115
- const reason = params.get('reason');
116
- if (reason !== null && reason.length > 0) {
117
- result.reason = reason;
118
- }
119
- }
120
-
121
- return result;
122
- }
123
-
124
- /**
125
- * Injectable dependencies for {@link consumeSsoReturn}.
126
- *
127
- * Every web seam (storage, location, history, web-detection) is injectable so
128
- * the function is fully unit-testable with fakes and so SSR / native callers
129
- * can supply their own (or rely on the defaults, which resolve to `window.*`
130
- * only when a browser is present). Defaults are evaluated lazily inside
131
- * `consumeSsoReturn` so importing this module never touches `window`.
132
- */
133
- export interface ConsumeSsoReturnDeps {
134
- /** Per-tab SSO state store. Default: `window.sessionStorage`. */
135
- storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
136
- /** The current location. Default: `window.location`. */
137
- location?: Pick<Location, 'hash' | 'origin' | 'pathname' | 'search'>;
138
- /** History API for fragment stripping / dest restore. Default: `window.history`. */
139
- history?: Pick<History, 'replaceState'>;
140
- /**
141
- * Whether the current environment is a web browser with usable
142
- * `sessionStorage`. Default: `typeof window !== 'undefined' && typeof
143
- * window.sessionStorage !== 'undefined'`.
144
- */
145
- isWeb?: () => boolean;
146
- /**
147
- * Optional debug hook invoked with the thrown error when the code exchange
148
- * fails. NEVER rethrown — `consumeSsoReturn` is total. Default: no-op.
149
- */
150
- onExchangeError?: (error: unknown) => void;
151
- /**
152
- * Notify URL-driven routers (Expo Router / React Navigation web) that the
153
- * location changed via `history.replaceState`, which does NOT itself emit
154
- * `popstate`. Default: dispatch a real `PopStateEvent` on `window` when
155
- * present; no-op off-web. Called ONLY after a successful same-origin
156
- * dest restore on the `ok` path (never when the dest is rejected/absent).
157
- * NEVER throws.
158
- */
159
- dispatchPopState?: () => void;
160
- /**
161
- * Hard, full-document navigation used to leave the internal callback path on
162
- * every NON-`ok` outcome (`none`/`error`, state-mismatch, missing code,
163
- * failed exchange, missing sessionId). A SOFT `history.replaceState` +
164
- * synthetic `popstate` does NOT reliably make Expo Router / TanStack Router
165
- * re-resolve away from the 404 they have already rendered for the
166
- * unregistered callback route — so for these outcomes (where there is no
167
- * in-memory session to preserve) a full navigation is both safe and
168
- * guaranteed to clear the 404. Default: `window.location.replace(url)` when
169
- * present; feature-detected end to end so it never throws off-web.
170
- */
171
- hardRedirect?: (url: string) => void;
172
- }
173
-
174
- /**
175
- * Consume an SSO return: the commit-free, security-critical kernel of the
176
- * cross-domain SSO `sso-return` cold-boot step.
177
- *
178
- * This performs the CSRF/fragment/exchange/dest-restore/loop-breaker sequence
179
- * and RETURNS the exchanged session (or `null`). It deliberately does NOT
180
- * commit any UI/auth state — each provider commits its own way AROUND this
181
- * (e.g. `@oxyhq/services` `OxyContext` calls its `handleWebSSOSession`,
182
- * `@oxyhq/auth` `WebOxyProvider` updates its React state). Hoisting the kernel
183
- * here keeps the two providers byte-for-byte identical on the parts that matter
184
- * for security (state validation, fragment stripping order, loop prevention).
185
- *
186
- * Security/loop invariants (preserved exactly from both former copies):
187
- * - The fragment is stripped via `history.replaceState` FIRST — before the
188
- * exchange — so the opaque code never lingers in the URL, browser history,
189
- * or a `Referer` header even if a later step throws.
190
- * - `state` must match (CSRF). A mismatch or a missing code sets the
191
- * NO_SESSION flag so `sso-bounce` is disabled (no rebounce loop).
192
- * - `none`/`error` outcomes set BOTH the NO_SESSION flag and the
193
- * outcome-independent attempted-flag (the load2 half of the loop proof).
194
- * - A throwing exchange is caught, reported via `onExchangeError`, and
195
- * treated exactly like "no session" (never loops, never rethrows).
196
- * - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
197
- * failed-exchange, no-sessionId) — not just ok — if the page landed on
198
- * {@link SSO_CALLBACK_PATH}, the user is taken to a same-origin TARGET so
199
- * they are never stranded on the internal callback path (which is an
200
- * unregistered route in every consumer router → a hard 404). The target is
201
- * the stored DEST when it parses as same-origin (an attacker-planted
202
- * cross-origin / protocol-relative dest is rejected), ELSE the app root
203
- * (`origin + '/'`). The DEST key is removed unconditionally.
204
- * - For the `ok` outcome the target is applied via a SOFT
205
- * `history.replaceState` + synthetic `popstate` so the freshly exchanged
206
- * in-memory session the provider is about to commit is preserved (no
207
- * reload). `popstate` is dispatched only on the `ok` same-origin restore.
208
- * - For every NON-`ok` outcome there is no in-memory session to preserve, and
209
- * the consumer router has ALREADY synchronously rendered its 404 for the
210
- * unregistered callback route — a soft replaceState+popstate does not
211
- * reliably make it re-resolve. So these outcomes perform a HARD
212
- * full-document navigation to the target (`hardRedirect`), which is both
213
- * safe (nothing to lose) and guaranteed to clear the 404 in every router.
214
- *
215
- * Total: this function NEVER throws. Off-web it is a no-op returning `null`.
216
- *
217
- * @param oxy - The exchange surface (`oxyServices.exchangeSsoCode`).
218
- * @param deps - Injectable web seams; see {@link ConsumeSsoReturnDeps}.
219
- * @returns The exchanged session on success, otherwise `null`.
220
- */
221
- export async function consumeSsoReturn(
222
- oxy: { exchangeSsoCode: (code: string, state?: string) => Promise<SessionLoginResponse> },
223
- deps: ConsumeSsoReturnDeps = {},
224
- ): Promise<SessionLoginResponse | null> {
225
- const isWeb =
226
- deps.isWeb ??
227
- (() =>
228
- typeof window !== 'undefined' &&
229
- typeof window.sessionStorage !== 'undefined');
230
-
231
- if (!isWeb()) {
232
- return null;
233
- }
234
-
235
- const storage = deps.storage ?? window.sessionStorage;
236
- const location = deps.location ?? window.location;
237
- const history = deps.history ?? window.history;
238
- const onExchangeError = deps.onExchangeError;
239
-
240
- // Default: emit a synthetic `popstate` so URL-driven routers re-sync after a
241
- // `history.replaceState` (which does NOT emit `popstate` on its own). Feature-
242
- // detected end to end so it never throws in any environment.
243
- const dispatchPopState =
244
- deps.dispatchPopState ??
245
- (() => {
246
- if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') {
247
- return;
248
- }
249
- if (typeof PopStateEvent !== 'undefined') {
250
- window.dispatchEvent(new PopStateEvent('popstate'));
251
- } else if (typeof Event !== 'undefined') {
252
- window.dispatchEvent(new Event('popstate'));
253
- }
254
- });
255
-
256
- // Default: a hard, full-document navigation used to leave the callback path
257
- // on non-`ok` outcomes. Feature-detected end to end so it never throws in any
258
- // environment (SSR / native / a stubbed location without `replace`).
259
- const hardRedirect =
260
- deps.hardRedirect ??
261
- ((url: string) => {
262
- if (
263
- typeof window !== 'undefined' &&
264
- window.location &&
265
- typeof window.location.replace === 'function'
266
- ) {
267
- window.location.replace(url);
268
- }
269
- });
270
-
271
- const ret = parseSsoReturnFragment(location.hash);
272
- if (!ret) {
273
- // Not an oxy_sso fragment — nothing to do (do NOT touch any flags).
274
- return null;
275
- }
276
-
277
- const origin = location.origin;
278
- const callbackBootstrapKey = ssoCallbackBootstrapKey(origin);
279
- const wasCallbackBootstrapped = storage.getItem(callbackBootstrapKey) === '1';
280
- const expectedState = storage.getItem(ssoStateKey(origin));
281
- const stateOk = !!ret.state && !!expectedState && ret.state === expectedState;
282
-
283
- // Strip the fragment FIRST so the opaque code never lingers in the address
284
- // bar, history, or a `Referer` — even if a later step throws.
285
- history.replaceState(null, '', location.pathname + location.search);
286
- storage.removeItem(ssoStateKey(origin));
287
-
288
- // The in-flight bounce is now resolved — drop its guard so a later cold boot
289
- // (e.g. after sign-out) can bounce again.
290
- storage.removeItem(ssoGuardKey(origin));
291
-
292
- const markNoSession = () => {
293
- storage.setItem(ssoNoSessionKey(origin), '1');
294
- // A return was consumed, so the probe definitively happened. Set the
295
- // outcome-independent attempted-flag too so the bounce can never re-fire
296
- // even if some consumer path skipped setting it pre-bounce.
297
- storage.setItem(ssoAttemptedKey(origin), '1');
298
- };
299
-
300
- // Compute the same-origin TARGET to leave the callback path for. Returns the
301
- // stored DEST when present AND it parses as same-origin (never honour a
302
- // cross-origin / protocol-relative dest that could have been planted to
303
- // redirect the user), ELSE the app root (`origin + '/'`) so the user is never
304
- // stranded on the internal callback path even when no dest was stored. The
305
- // DEST key is removed unconditionally. Returns the relative path+search+hash
306
- // (so it can be fed to either `history.replaceState` or a `hardRedirect`),
307
- // or `null` when the page is not on the callback path (nothing to leave).
308
- const consumeCallbackTarget = (): string | null => {
309
- storage.removeItem(callbackBootstrapKey);
310
- if (location.pathname !== SSO_CALLBACK_PATH && !wasCallbackBootstrapped) {
311
- // Not on the callback path — still drop the dest key (consumed) but there
312
- // is nothing to navigate away from.
313
- storage.removeItem(ssoDestKey(origin));
314
- return null;
315
- }
316
- const dest = storage.getItem(ssoDestKey(origin));
317
- storage.removeItem(ssoDestKey(origin));
318
- if (dest) {
319
- try {
320
- const destUrl = new URL(dest, origin);
321
- if (destUrl.origin === origin) {
322
- return destUrl.pathname + destUrl.search + destUrl.hash;
323
- }
324
- } catch {
325
- // Malformed stored destination — fall through to the app-root fallback.
326
- }
327
- }
328
- // No dest, a cross-origin/protocol-relative dest, or an unparseable dest:
329
- // fall back to the app root so the router always leaves the 404.
330
- return '/';
331
- };
332
-
333
- // Non-`ok` outcomes: there is no in-memory session to preserve, and the
334
- // consumer router has already rendered its 404 for the unregistered callback
335
- // route — a soft replaceState+popstate does not reliably make it re-resolve.
336
- // Perform a HARD full-document navigation to the target (safe: nothing to
337
- // lose; guaranteed: every router leaves the 404). Off the callback path this
338
- // is a no-op (target is null).
339
- const leaveCallbackHard = (): void => {
340
- const target = consumeCallbackTarget();
341
- if (target !== null) {
342
- hardRedirect(origin + target);
343
- }
344
- };
345
-
346
- if (ret.kind === 'none' || ret.kind === 'error') {
347
- // The central IdP had no session (or the bounce failed). Record it so we do
348
- // not bounce again this tab — the definitive loop breaker.
349
- markNoSession();
350
- leaveCallbackHard();
351
- return null;
352
- }
353
-
354
- if (!stateOk || !ret.code) {
355
- // Forged / replayed / stale fragment, or a malformed ok with no code. Treat
356
- // exactly like "no session": never exchange, never loop.
357
- markNoSession();
358
- leaveCallbackHard();
359
- return null;
360
- }
361
-
362
- let session: SessionLoginResponse | undefined;
363
- try {
364
- session = await oxy.exchangeSsoCode(ret.code, ret.state);
365
- } catch (error) {
366
- onExchangeError?.(error);
367
- markNoSession();
368
- leaveCallbackHard();
369
- return null;
370
- }
371
-
372
- if (!session?.sessionId) {
373
- markNoSession();
374
- leaveCallbackHard();
375
- return null;
376
- }
377
-
378
- // `ok`: the provider is about to commit the freshly exchanged in-memory
379
- // session — do NOT hard-redirect (a full navigation would discard it). Use a
380
- // SOFT `history.replaceState` to the target + a synthetic `popstate` so
381
- // URL-driven routers re-sync to the restored route without a reload.
382
- const target = consumeCallbackTarget();
383
- if (target !== null) {
384
- history.replaceState(null, '', target);
385
- dispatchPopState();
386
- }
387
-
388
- return session;
389
- }