@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,219 +0,0 @@
1
- /**
2
- * Central SSO bounce helpers — per-origin key builders, the bounce URL builder,
3
- * the central-IdP predicate, and the self-healing guard.
4
- *
5
- * These are a wire/storage contract shared with the IdP and every consumer, so
6
- * the exact key strings, the 30s TTL, and the same-origin / parse-failure
7
- * behaviour are all asserted explicitly.
8
- */
9
-
10
- import {
11
- SSO_CALLBACK_PATH,
12
- SSO_GUARD_TTL_MS,
13
- ssoStateKey,
14
- ssoGuardKey,
15
- ssoDestKey,
16
- ssoNoSessionKey,
17
- ssoAttemptedKey,
18
- ssoPriorSessionKey,
19
- ssoSignedOutKey,
20
- buildSsoBounceUrl,
21
- isCentralIdPOrigin,
22
- guardActive,
23
- silentRestoreSuppressed,
24
- allowSsoBounce,
25
- } from '../ssoBounce';
26
- import { CENTRAL_AUTH_URL } from '../authWebUrl';
27
-
28
- describe('SSO bounce constants', () => {
29
- it('pins the callback path and guard TTL (wire/storage contract)', () => {
30
- expect(SSO_CALLBACK_PATH).toBe('/__oxy/sso-callback');
31
- expect(SSO_GUARD_TTL_MS).toBe(30_000);
32
- });
33
- });
34
-
35
- describe('per-origin key builders', () => {
36
- const origin = 'https://mention.earth';
37
-
38
- it('builds the exact contracted key strings', () => {
39
- expect(ssoStateKey(origin)).toBe('oxy_sso_state:https://mention.earth');
40
- expect(ssoGuardKey(origin)).toBe('oxy_sso_guard:https://mention.earth');
41
- expect(ssoDestKey(origin)).toBe('oxy_sso_dest:https://mention.earth');
42
- expect(ssoNoSessionKey(origin)).toBe('oxy_sso_no_session:https://mention.earth');
43
- expect(ssoAttemptedKey(origin)).toBe('oxy_sso_attempted:https://mention.earth');
44
- expect(ssoPriorSessionKey(origin)).toBe('oxy_sso_prior_session:https://mention.earth');
45
- expect(ssoSignedOutKey(origin)).toBe('oxy_signed_out:https://mention.earth');
46
- });
47
-
48
- it('namespaces keys per origin so two RPs never collide', () => {
49
- expect(ssoStateKey('https://a.test')).not.toBe(ssoStateKey('https://b.test'));
50
- expect(ssoPriorSessionKey('https://a.test')).not.toBe(ssoPriorSessionKey('https://b.test'));
51
- expect(ssoSignedOutKey('https://a.test')).not.toBe(ssoSignedOutKey('https://b.test'));
52
- });
53
- });
54
-
55
- describe('silentRestoreSuppressed (deliberately-signed-out gate)', () => {
56
- const origin = 'https://accounts.oxy.so';
57
-
58
- function storageWith(value: string | null): Pick<Storage, 'getItem'> {
59
- return { getItem: (key: string) => (key === ssoSignedOutKey(origin) ? value : null) };
60
- }
61
-
62
- it('is SUPPRESSED when the signed-out flag is set to "1" for this origin', () => {
63
- expect(silentRestoreSuppressed(storageWith('1'), origin)).toBe(true);
64
- });
65
-
66
- it('is NOT suppressed when the flag is absent (normal restore)', () => {
67
- expect(silentRestoreSuppressed(storageWith(null), origin)).toBe(false);
68
- });
69
-
70
- it('is NOT suppressed for any non-"1" value (only the exact set value gates)', () => {
71
- expect(silentRestoreSuppressed(storageWith('0'), origin)).toBe(false);
72
- expect(silentRestoreSuppressed(storageWith(''), origin)).toBe(false);
73
- });
74
-
75
- it('is per-origin: another origin\'s flag does not suppress this one', () => {
76
- const otherOriginFlag: Pick<Storage, 'getItem'> = {
77
- getItem: (key: string) => (key === ssoSignedOutKey('https://other.test') ? '1' : null),
78
- };
79
- expect(silentRestoreSuppressed(otherOriginFlag, origin)).toBe(false);
80
- });
81
-
82
- it('fails safe (NOT suppressed, never throws) when getItem throws', () => {
83
- const throwing: Pick<Storage, 'getItem'> = {
84
- getItem: () => {
85
- throw new Error('storage locked');
86
- },
87
- };
88
- expect(silentRestoreSuppressed(throwing, origin)).toBe(false);
89
- });
90
- });
91
-
92
- describe('allowSsoBounce (smart returning-visitor gate)', () => {
93
- it('ALLOWS a returning visitor (prior-session hint) with no local session', () => {
94
- // The core of fix B: a returning user whose local session has expired still
95
- // gets ONE establish bounce so a central-only cross-domain session recovers.
96
- expect(
97
- allowSsoBounce({ hasPriorSession: true, hasLocalSession: false }),
98
- ).toBe(true);
99
- });
100
-
101
- it('SUPPRESSES a truly first-time anonymous visitor (no hint, no local session)', () => {
102
- // The smart default (the ONLY behaviour): a first-time visitor browses
103
- // anonymously instead of being force-redirected to the central IdP.
104
- expect(
105
- allowSsoBounce({ hasPriorSession: false, hasLocalSession: false }),
106
- ).toBe(false);
107
- });
108
-
109
- it('ALLOWS when a local session was recovered this boot (spec fidelity)', () => {
110
- expect(
111
- allowSsoBounce({ hasPriorSession: false, hasLocalSession: true }),
112
- ).toBe(true);
113
- });
114
- });
115
-
116
- describe('buildSsoBounceUrl', () => {
117
- const origin = 'https://mention.earth';
118
-
119
- it('targets the central IdP /sso with all required params (default base)', () => {
120
- const url = new URL(buildSsoBounceUrl(origin, 'state-123'));
121
-
122
- expect(url.origin).toBe('https://auth.oxy.so');
123
- expect(url.pathname).toBe('/sso');
124
- expect(url.searchParams.get('prompt')).toBe('none');
125
- expect(url.searchParams.get('client_id')).toBe(origin);
126
- expect(url.searchParams.get('return_to')).toBe(origin + SSO_CALLBACK_PATH);
127
- expect(url.searchParams.get('state')).toBe('state-123');
128
- });
129
-
130
- it('honours an explicit authWebUrl override (staging IdP)', () => {
131
- const url = new URL(
132
- buildSsoBounceUrl(origin, 'state-xyz', 'https://auth.mention.earth'),
133
- );
134
-
135
- expect(url.origin).toBe('https://auth.mention.earth');
136
- expect(url.pathname).toBe('/sso');
137
- expect(url.searchParams.get('client_id')).toBe(origin);
138
- expect(url.searchParams.get('state')).toBe('state-xyz');
139
- });
140
-
141
- it('falls back to the central default for an empty override', () => {
142
- const url = new URL(buildSsoBounceUrl(origin, 's', undefined));
143
- expect(url.origin).toBe('https://auth.oxy.so');
144
- });
145
- });
146
-
147
- describe('isCentralIdPOrigin', () => {
148
- it('matches the central IdP origin', () => {
149
- expect(isCentralIdPOrigin('https://auth.oxy.so')).toBe(true);
150
- expect(isCentralIdPOrigin(CENTRAL_AUTH_URL)).toBe(true);
151
- });
152
-
153
- it('normalises a trailing-slash / path candidate via URL origin', () => {
154
- expect(isCentralIdPOrigin('https://auth.oxy.so/')).toBe(true);
155
- expect(isCentralIdPOrigin('https://auth.oxy.so/sso')).toBe(true);
156
- });
157
-
158
- it('rejects a non-central origin', () => {
159
- expect(isCentralIdPOrigin('https://mention.earth')).toBe(false);
160
- expect(isCentralIdPOrigin('https://auth.mention.earth')).toBe(false);
161
- });
162
-
163
- it('returns false for an unparseable candidate', () => {
164
- expect(isCentralIdPOrigin('not a url')).toBe(false);
165
- expect(isCentralIdPOrigin('')).toBe(false);
166
- });
167
- });
168
-
169
- describe('guardActive', () => {
170
- const origin = 'https://mention.earth';
171
-
172
- function storageWith(value: string | null): Pick<Storage, 'getItem'> {
173
- return { getItem: (key: string) => (key === ssoGuardKey(origin) ? value : null) };
174
- }
175
-
176
- it('is active for a present, fresh guard', () => {
177
- const now = 1_000_000;
178
- const storage = storageWith(String(now - 1_000)); // 1s old, well under TTL
179
- expect(guardActive(storage, origin, now)).toBe(true);
180
- });
181
-
182
- it('is inactive for a present but stale guard (older than TTL)', () => {
183
- const now = 1_000_000;
184
- const storage = storageWith(String(now - SSO_GUARD_TTL_MS - 1));
185
- expect(guardActive(storage, origin, now)).toBe(false);
186
- });
187
-
188
- it('is inactive for a guard exactly at the TTL boundary (strict <)', () => {
189
- const now = 1_000_000;
190
- const storage = storageWith(String(now - SSO_GUARD_TTL_MS));
191
- expect(guardActive(storage, origin, now)).toBe(false);
192
- });
193
-
194
- it('is inactive when the guard is missing', () => {
195
- expect(guardActive(storageWith(null), origin, 1_000_000)).toBe(false);
196
- });
197
-
198
- it('is inactive for an empty-string guard value', () => {
199
- expect(guardActive(storageWith(''), origin, 1_000_000)).toBe(false);
200
- });
201
-
202
- it('is inactive for a malformed (non-numeric) guard value', () => {
203
- expect(guardActive(storageWith('not-a-number'), origin, 1_000_000)).toBe(false);
204
- });
205
-
206
- it('is inactive (never throws) when getItem itself throws', () => {
207
- const throwing: Pick<Storage, 'getItem'> = {
208
- getItem: () => {
209
- throw new Error('storage locked');
210
- },
211
- };
212
- expect(guardActive(throwing, origin, 1_000_000)).toBe(false);
213
- });
214
-
215
- it('defaults now to Date.now() when omitted', () => {
216
- const storage = storageWith(String(Date.now() - 500));
217
- expect(guardActive(storage, origin)).toBe(true);
218
- });
219
- });
@@ -1,204 +0,0 @@
1
- /**
2
- * `establishIdpSessionAfterClaim` — the post-claim durable-session hop.
3
- *
4
- * After a WEB device-flow ("Sign in with Oxy" QR) claim commits, the app holds
5
- * only in-memory tokens: no `fedcm_session` cookie was ever planted at the IdP,
6
- * so a reload cannot re-mint a token. This primitive performs ONE top-level
7
- * establish hop (via the RP-minted establish-URL) so the per-apex IdP cookie is
8
- * planted and a reload restores via the existing `sso-return` / silent-iframe
9
- * paths.
10
- *
11
- * Invariants pinned here:
12
- * - web only (no-op off-web / native);
13
- * - never fires while sitting on the central IdP origin (would loop);
14
- * - persists the SAME bounce state (`ssoStateKey`/`ssoGuardKey`/`ssoDestKey`)
15
- * the `buildSsoBounceUrl` machinery primes, so the post-bounce `sso-return`
16
- * step validates the state, exchanges the code, and restores the dest;
17
- * - persists ONLY after the establish-URL request succeeds (no stale state on
18
- * failure);
19
- * - navigates exactly once, to the server-returned establish URL;
20
- * - total: an establish-request failure leaves the committed in-memory session
21
- * untouched, does NOT navigate, and returns `false` (user no worse off).
22
- */
23
-
24
- import { establishIdpSessionAfterClaim } from '../ssoEstablish';
25
- import {
26
- ssoStateKey,
27
- ssoGuardKey,
28
- ssoDestKey,
29
- } from '../ssoBounce';
30
- import { CENTRAL_AUTH_URL } from '../authWebUrl';
31
-
32
- const RP_ORIGIN = 'https://accounts.oxy.so';
33
- const RP_HREF = 'https://accounts.oxy.so/settings';
34
- const ESTABLISH_URL =
35
- 'https://auth.oxy.so/sso/establish?et=jwt.abc.def&return_to=https%3A%2F%2Faccounts.oxy.so%2F__oxy%2Fsso-callback&state=state-xyz';
36
-
37
- function makeStorage(): {
38
- storage: Pick<Storage, 'getItem' | 'setItem'>;
39
- map: Map<string, string>;
40
- } {
41
- const map = new Map<string, string>();
42
- return {
43
- map,
44
- storage: {
45
- getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
46
- setItem: (k: string, v: string) => {
47
- map.set(k, v);
48
- },
49
- },
50
- };
51
- }
52
-
53
- describe('establishIdpSessionAfterClaim', () => {
54
- it('requests the establish URL with the RP origin + generated state and navigates once (web)', async () => {
55
- const { storage, map } = makeStorage();
56
- const navigated: string[] = [];
57
- const requestSsoEstablishUrl = jest.fn(async (origin: string, state: string) => {
58
- expect(origin).toBe(RP_ORIGIN);
59
- expect(state).toBe('state-xyz');
60
- return { establishUrl: ESTABLISH_URL };
61
- });
62
-
63
- const result = await establishIdpSessionAfterClaim(
64
- { requestSsoEstablishUrl },
65
- {
66
- isWeb: () => true,
67
- storage,
68
- location: { origin: RP_ORIGIN, href: RP_HREF },
69
- navigate: (url) => navigated.push(url),
70
- generateState: () => 'state-xyz',
71
- now: () => 1_700_000_000_000,
72
- },
73
- );
74
-
75
- expect(result).toBe(true);
76
- expect(requestSsoEstablishUrl).toHaveBeenCalledTimes(1);
77
- expect(navigated).toEqual([ESTABLISH_URL]);
78
-
79
- // Bounce state persisted so the post-bounce `sso-return` step validates it.
80
- expect(map.get(ssoStateKey(RP_ORIGIN))).toBe('state-xyz');
81
- expect(map.get(ssoGuardKey(RP_ORIGIN))).toBe(String(1_700_000_000_000));
82
- expect(map.get(ssoDestKey(RP_ORIGIN))).toBe(RP_HREF);
83
- });
84
-
85
- it('is a no-op off-web (native): no request, no navigation, no state', async () => {
86
- const { storage, map } = makeStorage();
87
- const navigated: string[] = [];
88
- const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: ESTABLISH_URL }));
89
-
90
- const result = await establishIdpSessionAfterClaim(
91
- { requestSsoEstablishUrl },
92
- {
93
- isWeb: () => false,
94
- storage,
95
- location: { origin: RP_ORIGIN, href: RP_HREF },
96
- navigate: (url) => navigated.push(url),
97
- },
98
- );
99
-
100
- expect(result).toBe(false);
101
- expect(requestSsoEstablishUrl).not.toHaveBeenCalled();
102
- expect(navigated).toEqual([]);
103
- expect(map.size).toBe(0);
104
- });
105
-
106
- it('never fires while sitting on the central IdP origin (loop guard)', async () => {
107
- const { storage, map } = makeStorage();
108
- const navigated: string[] = [];
109
- const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: ESTABLISH_URL }));
110
-
111
- const result = await establishIdpSessionAfterClaim(
112
- { requestSsoEstablishUrl },
113
- {
114
- isWeb: () => true,
115
- storage,
116
- location: { origin: new URL(CENTRAL_AUTH_URL).origin, href: `${CENTRAL_AUTH_URL}/` },
117
- navigate: (url) => navigated.push(url),
118
- },
119
- );
120
-
121
- expect(result).toBe(false);
122
- expect(requestSsoEstablishUrl).not.toHaveBeenCalled();
123
- expect(navigated).toEqual([]);
124
- expect(map.size).toBe(0);
125
- });
126
-
127
- it('on an establish-request failure: no navigation, no persisted state, returns false (single attempt)', async () => {
128
- const { storage, map } = makeStorage();
129
- const navigated: string[] = [];
130
- const onError = jest.fn();
131
- const requestSsoEstablishUrl = jest.fn(async () => {
132
- throw new Error('403 unapproved origin');
133
- });
134
-
135
- const result = await establishIdpSessionAfterClaim(
136
- { requestSsoEstablishUrl },
137
- {
138
- isWeb: () => true,
139
- storage,
140
- location: { origin: RP_ORIGIN, href: RP_HREF },
141
- navigate: (url) => navigated.push(url),
142
- generateState: () => 'state-xyz',
143
- onError,
144
- },
145
- );
146
-
147
- expect(result).toBe(false);
148
- expect(requestSsoEstablishUrl).toHaveBeenCalledTimes(1);
149
- expect(navigated).toEqual([]);
150
- // No stale bounce state left behind on failure.
151
- expect(map.size).toBe(0);
152
- expect(onError).toHaveBeenCalledTimes(1);
153
- });
154
-
155
- it('does not navigate when the server returns no establish URL', async () => {
156
- const { storage, map } = makeStorage();
157
- const navigated: string[] = [];
158
- const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: '' }));
159
-
160
- const result = await establishIdpSessionAfterClaim(
161
- { requestSsoEstablishUrl },
162
- {
163
- isWeb: () => true,
164
- storage,
165
- location: { origin: RP_ORIGIN, href: RP_HREF },
166
- navigate: (url) => navigated.push(url),
167
- generateState: () => 'state-xyz',
168
- },
169
- );
170
-
171
- expect(result).toBe(false);
172
- expect(navigated).toEqual([]);
173
- expect(map.size).toBe(0);
174
- });
175
-
176
- it.each([
177
- ['unparseable', 'not a url'],
178
- ['non-https (http)', 'http://auth.oxy.so/sso/establish?et=x&state=s'],
179
- ['wrong path', 'https://auth.oxy.so/evil?et=x&state=s'],
180
- ['non-auth host', 'https://evil.oxy.so/sso/establish?et=x&state=s'],
181
- ])(
182
- 'aborts silently (no navigation, no state) for a %s establish URL',
183
- async (_label, badUrl) => {
184
- const { storage, map } = makeStorage();
185
- const navigated: string[] = [];
186
- const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: badUrl }));
187
-
188
- const result = await establishIdpSessionAfterClaim(
189
- { requestSsoEstablishUrl },
190
- {
191
- isWeb: () => true,
192
- storage,
193
- location: { origin: RP_ORIGIN, href: RP_HREF },
194
- navigate: (url) => navigated.push(url),
195
- generateState: () => 'state-xyz',
196
- },
197
- );
198
-
199
- expect(result).toBe(false);
200
- expect(navigated).toEqual([]);
201
- expect(map.size).toBe(0);
202
- },
203
- );
204
- });
@@ -1,276 +0,0 @@
1
- /**
2
- * `parseSsoReturnFragment` — SSO return fragment parsing.
3
- *
4
- * The central IdP returns the RP via a top-level redirect with the bounce
5
- * result in the URL fragment. The parser must be pure, total (never throws),
6
- * and report `kind` strictly as one of `'ok' | 'none' | 'error'`, returning
7
- * `null` for anything that is not an oxy_sso fragment.
8
- */
9
-
10
- import type { SessionLoginResponse } from '../../models/session';
11
- import { consumeSsoReturn, parseSsoReturnFragment } from '../ssoReturn';
12
- import {
13
- getSsoCallbackBootstrapScript,
14
- ssoAttemptedKey,
15
- ssoCallbackBootstrapKey,
16
- ssoDestKey,
17
- ssoNoSessionKey,
18
- ssoStateKey,
19
- } from '../ssoBounce';
20
-
21
- class MemorySsoStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
22
- private readonly values = new Map<string, string>();
23
-
24
- getItem(key: string): string | null {
25
- return this.values.get(key) ?? null;
26
- }
27
-
28
- setItem(key: string, value: string): void {
29
- this.values.set(key, value);
30
- }
31
-
32
- removeItem(key: string): void {
33
- this.values.delete(key);
34
- }
35
- }
36
-
37
- const ORIGIN = 'https://app.mention.earth';
38
-
39
- const exchangedSession: SessionLoginResponse = {
40
- sessionId: 'sess_sso',
41
- deviceId: 'device_sso',
42
- accessToken: 'access_sso',
43
- expiresAt: '2030-01-01T00:00:00.000Z',
44
- user: { id: 'user_sso', username: 'sso-user' },
45
- };
46
-
47
- describe('parseSsoReturnFragment', () => {
48
- describe('ok', () => {
49
- it('parses a success fragment with code and state', () => {
50
- const result = parseSsoReturnFragment('#oxy_sso=ok&code=abc123&state=xyz');
51
-
52
- expect(result).toEqual({ kind: 'ok', code: 'abc123', state: 'xyz' });
53
- });
54
-
55
- it('parses a success fragment without a leading #', () => {
56
- const result = parseSsoReturnFragment('oxy_sso=ok&code=abc123&state=xyz');
57
-
58
- expect(result).toEqual({ kind: 'ok', code: 'abc123', state: 'xyz' });
59
- });
60
-
61
- it('omits code when ok carries no code', () => {
62
- const result = parseSsoReturnFragment('#oxy_sso=ok&state=xyz');
63
-
64
- expect(result).toEqual({ kind: 'ok', state: 'xyz' });
65
- expect(result?.code).toBeUndefined();
66
- });
67
-
68
- it('URL-decodes percent-encoded values', () => {
69
- const result = parseSsoReturnFragment('#oxy_sso=ok&code=a%2Bb%2Fc&state=s%20t');
70
-
71
- expect(result).toEqual({ kind: 'ok', code: 'a+b/c', state: 's t' });
72
- });
73
-
74
- it('ignores a stray reason on an ok outcome', () => {
75
- const result = parseSsoReturnFragment('#oxy_sso=ok&code=abc123&state=xyz&reason=no_cookie');
76
-
77
- expect(result).toEqual({ kind: 'ok', code: 'abc123', state: 'xyz' });
78
- expect(result?.reason).toBeUndefined();
79
- });
80
- });
81
-
82
- describe('none', () => {
83
- it('parses a none fragment and carries state but never a code', () => {
84
- const result = parseSsoReturnFragment('#oxy_sso=none&state=xyz');
85
-
86
- expect(result).toEqual({ kind: 'none', state: 'xyz' });
87
- });
88
-
89
- it('ignores a stray code on a none outcome', () => {
90
- const result = parseSsoReturnFragment('#oxy_sso=none&code=leaked&state=xyz');
91
-
92
- expect(result).toEqual({ kind: 'none', state: 'xyz' });
93
- expect(result?.code).toBeUndefined();
94
- });
95
-
96
- it('carries a machine-readable reason on a none outcome', () => {
97
- const result = parseSsoReturnFragment('#oxy_sso=none&reason=no_cookie&state=xyz');
98
-
99
- expect(result).toEqual({ kind: 'none', state: 'xyz', reason: 'no_cookie' });
100
- });
101
-
102
- it('omits reason when the none outcome carries none', () => {
103
- const result = parseSsoReturnFragment('#oxy_sso=none&state=xyz');
104
-
105
- expect(result?.reason).toBeUndefined();
106
- });
107
- });
108
-
109
- describe('error', () => {
110
- it('parses an error fragment', () => {
111
- const result = parseSsoReturnFragment('#oxy_sso=error&state=xyz');
112
-
113
- expect(result).toEqual({ kind: 'error', state: 'xyz' });
114
- });
115
-
116
- it('ignores a stray code on an error outcome', () => {
117
- const result = parseSsoReturnFragment('#oxy_sso=error&code=leaked');
118
-
119
- expect(result).toEqual({ kind: 'error' });
120
- expect(result?.code).toBeUndefined();
121
- });
122
-
123
- it('carries a machine-readable reason on an error outcome', () => {
124
- const result = parseSsoReturnFragment('#oxy_sso=error&reason=no_grant_establish');
125
-
126
- expect(result).toEqual({ kind: 'error', reason: 'no_grant_establish' });
127
- });
128
- });
129
-
130
- describe('null (not an oxy_sso fragment)', () => {
131
- it('returns null for an empty string', () => {
132
- expect(parseSsoReturnFragment('')).toBeNull();
133
- });
134
-
135
- it('returns null for a bare #', () => {
136
- expect(parseSsoReturnFragment('#')).toBeNull();
137
- });
138
-
139
- it('returns null for undefined', () => {
140
- expect(parseSsoReturnFragment(undefined)).toBeNull();
141
- });
142
-
143
- it('returns null for null', () => {
144
- expect(parseSsoReturnFragment(null)).toBeNull();
145
- });
146
-
147
- it('returns null for a fragment without oxy_sso', () => {
148
- expect(parseSsoReturnFragment('#access_token=foo&state=bar')).toBeNull();
149
- });
150
-
151
- it('returns null for an unrecognised oxy_sso value', () => {
152
- expect(parseSsoReturnFragment('#oxy_sso=bogus&code=x')).toBeNull();
153
- });
154
-
155
- it('returns null for an empty oxy_sso value', () => {
156
- expect(parseSsoReturnFragment('#oxy_sso=&code=x')).toBeNull();
157
- });
158
- });
159
-
160
- describe('malformed / defensive', () => {
161
- it('never throws and returns a valid kind for junk after the marker', () => {
162
- const result = parseSsoReturnFragment('#oxy_sso=ok&=&&&code=c&&');
163
-
164
- expect(result?.kind).toBe('ok');
165
- expect(result?.code).toBe('c');
166
- });
167
-
168
- it('always reports a kind in the strict union', () => {
169
- for (const input of [
170
- '#oxy_sso=ok',
171
- '#oxy_sso=none',
172
- '#oxy_sso=error',
173
- ]) {
174
- const result = parseSsoReturnFragment(input);
175
- expect(result).not.toBeNull();
176
- expect(['ok', 'none', 'error']).toContain(result?.kind);
177
- }
178
- });
179
- });
180
- });
181
-
182
- describe('consumeSsoReturn pre-hydration callback bootstrap', () => {
183
- it('continues an ok callback after the HTML bootstrap moved the URL to a hydratable route', async () => {
184
- const storage = new MemorySsoStorage();
185
- const replaceStateCalls: string[] = [];
186
- const dispatchPopState = jest.fn();
187
- const hardRedirect = jest.fn();
188
- const exchangeSsoCode = jest.fn(async (): Promise<SessionLoginResponse> => exchangedSession);
189
-
190
- storage.setItem(ssoStateKey(ORIGIN), 'state-ok');
191
- storage.setItem(ssoDestKey(ORIGIN), `${ORIGIN}/explore?tab=home#top`);
192
- storage.setItem(ssoCallbackBootstrapKey(ORIGIN), '1');
193
-
194
- const session = await consumeSsoReturn(
195
- { exchangeSsoCode },
196
- {
197
- isWeb: () => true,
198
- storage,
199
- location: {
200
- hash: '#oxy_sso=ok&code=opaque-code&state=state-ok',
201
- origin: ORIGIN,
202
- pathname: '/',
203
- search: '',
204
- },
205
- history: {
206
- replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
207
- replaceStateCalls.push(String(url ?? ''));
208
- },
209
- },
210
- dispatchPopState,
211
- hardRedirect,
212
- },
213
- );
214
-
215
- expect(session).toBe(exchangedSession);
216
- expect(exchangeSsoCode).toHaveBeenCalledWith('opaque-code', 'state-ok');
217
- expect(replaceStateCalls).toEqual(['/', '/explore?tab=home#top']);
218
- expect(dispatchPopState).toHaveBeenCalledTimes(1);
219
- expect(hardRedirect).not.toHaveBeenCalled();
220
- expect(storage.getItem(ssoCallbackBootstrapKey(ORIGIN))).toBeNull();
221
- expect(storage.getItem(ssoDestKey(ORIGIN))).toBeNull();
222
- expect(storage.getItem(ssoNoSessionKey(ORIGIN))).toBeNull();
223
- });
224
-
225
- it('leaves a bootstrapped none callback with loop breakers set and no exchange', async () => {
226
- const storage = new MemorySsoStorage();
227
- const replaceStateCalls: string[] = [];
228
- const dispatchPopState = jest.fn();
229
- const hardRedirect = jest.fn();
230
- const exchangeSsoCode = jest.fn(async (): Promise<SessionLoginResponse> => exchangedSession);
231
-
232
- storage.setItem(ssoStateKey(ORIGIN), 'state-none');
233
- storage.setItem(ssoDestKey(ORIGIN), `${ORIGIN}/library`);
234
- storage.setItem(ssoCallbackBootstrapKey(ORIGIN), '1');
235
-
236
- const session = await consumeSsoReturn(
237
- { exchangeSsoCode },
238
- {
239
- isWeb: () => true,
240
- storage,
241
- location: {
242
- hash: '#oxy_sso=none&state=state-none',
243
- origin: ORIGIN,
244
- pathname: '/',
245
- search: '',
246
- },
247
- history: {
248
- replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
249
- replaceStateCalls.push(String(url ?? ''));
250
- },
251
- },
252
- dispatchPopState,
253
- hardRedirect,
254
- },
255
- );
256
-
257
- expect(session).toBeNull();
258
- expect(exchangeSsoCode).not.toHaveBeenCalled();
259
- expect(replaceStateCalls).toEqual(['/']);
260
- expect(dispatchPopState).not.toHaveBeenCalled();
261
- expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/library`);
262
- expect(storage.getItem(ssoCallbackBootstrapKey(ORIGIN))).toBeNull();
263
- expect(storage.getItem(ssoDestKey(ORIGIN))).toBeNull();
264
- expect(storage.getItem(ssoNoSessionKey(ORIGIN))).toBe('1');
265
- expect(storage.getItem(ssoAttemptedKey(ORIGIN))).toBe('1');
266
- });
267
-
268
- it('exposes a pre-hydration script that preserves the SSO fragment', () => {
269
- const script = getSsoCallbackBootstrapScript();
270
-
271
- expect(script).toContain('/__oxy/sso-callback');
272
- expect(script).toContain('oxy_sso=');
273
- expect(script).toContain('window.history.replaceState');
274
- expect(script).toContain('window.location.hash');
275
- });
276
- });