@oxyhq/core 5.5.0 → 7.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 (196) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +7 -4
  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 +54 -45
  12. package/dist/cjs/mixins/OxyServices.accounts.js +13 -26
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  15. package/dist/cjs/mixins/index.js +8 -17
  16. package/dist/cjs/server/index.js +2 -2
  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/shared/utils/debugUtils.js +3 -3
  21. package/dist/cjs/utils/accountUtils.js +1 -55
  22. package/dist/cjs/utils/authWebUrl.js +10 -39
  23. package/dist/cjs/utils/coldBoot.js +10 -8
  24. package/dist/cjs/utils/platform.js +19 -0
  25. package/dist/cjs/utils/registrableApex.js +49 -0
  26. package/dist/cjs/utils/ssoBounce.js +15 -362
  27. package/dist/cjs/utils/validationUtils.js +57 -0
  28. package/dist/esm/.tsbuildinfo +1 -1
  29. package/dist/esm/HttpService.js +7 -4
  30. package/dist/esm/OxyServices.base.js +7 -102
  31. package/dist/esm/boot/coldBootV2.js +344 -0
  32. package/dist/esm/boot/deviceBootReturn.js +146 -0
  33. package/dist/esm/crypto/keyManager.js +95 -0
  34. package/dist/esm/i18n/locales/en-US.json +13 -1
  35. package/dist/esm/i18n/locales/es-ES.json +13 -1
  36. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  37. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  38. package/dist/esm/index.js +34 -17
  39. package/dist/esm/mixins/OxyServices.accounts.js +13 -26
  40. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  41. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  42. package/dist/esm/mixins/index.js +8 -17
  43. package/dist/esm/server/index.js +1 -1
  44. package/dist/esm/session/SessionClient.js +40 -1
  45. package/dist/esm/session/authStateStore.js +278 -0
  46. package/dist/esm/session/refresh.js +257 -0
  47. package/dist/esm/shared/utils/debugUtils.js +3 -3
  48. package/dist/esm/utils/accountUtils.js +0 -53
  49. package/dist/esm/utils/authWebUrl.js +9 -37
  50. package/dist/esm/utils/coldBoot.js +10 -8
  51. package/dist/esm/utils/platform.js +18 -0
  52. package/dist/esm/utils/registrableApex.js +46 -0
  53. package/dist/esm/utils/ssoBounce.js +14 -345
  54. package/dist/esm/utils/validationUtils.js +56 -0
  55. package/dist/types/.tsbuildinfo +1 -1
  56. package/dist/types/HttpService.d.ts +14 -1
  57. package/dist/types/OxyServices.base.d.ts +0 -52
  58. package/dist/types/OxyServices.d.ts +0 -25
  59. package/dist/types/boot/coldBootV2.d.ts +76 -0
  60. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  61. package/dist/types/crypto/keyManager.d.ts +21 -0
  62. package/dist/types/index.d.ts +14 -21
  63. package/dist/types/mixins/OxyServices.accounts.d.ts +24 -29
  64. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  66. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  67. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  68. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  71. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  72. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  83. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  84. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  85. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  86. package/dist/types/mixins/index.d.ts +5 -9
  87. package/dist/types/models/interfaces.d.ts +0 -67
  88. package/dist/types/models/session.d.ts +4 -5
  89. package/dist/types/server/index.d.ts +1 -1
  90. package/dist/types/session/SessionClient.d.ts +26 -1
  91. package/dist/types/session/authStateStore.d.ts +119 -0
  92. package/dist/types/session/refresh.d.ts +93 -0
  93. package/dist/types/shared/utils/debugUtils.d.ts +3 -3
  94. package/dist/types/utils/accountUtils.d.ts +2 -17
  95. package/dist/types/utils/authWebUrl.d.ts +9 -35
  96. package/dist/types/utils/coldBoot.d.ts +17 -14
  97. package/dist/types/utils/platform.d.ts +14 -0
  98. package/dist/types/utils/registrableApex.d.ts +31 -0
  99. package/dist/types/utils/ssoBounce.d.ts +14 -280
  100. package/dist/types/utils/validationUtils.d.ts +15 -0
  101. package/package.json +2 -2
  102. package/src/HttpService.ts +20 -4
  103. package/src/OxyServices.base.ts +7 -112
  104. package/src/OxyServices.ts +0 -38
  105. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  106. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  107. package/src/boot/coldBootV2.ts +426 -0
  108. package/src/boot/deviceBootReturn.ts +195 -0
  109. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  110. package/src/crypto/keyManager.ts +101 -0
  111. package/src/i18n/locales/en-US.json +13 -1
  112. package/src/i18n/locales/es-ES.json +13 -1
  113. package/src/index.ts +75 -65
  114. package/src/mixins/OxyServices.accounts.ts +27 -39
  115. package/src/mixins/OxyServices.auth.ts +78 -253
  116. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  117. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  118. package/src/mixins/__tests__/accounts.test.ts +17 -44
  119. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  120. package/src/mixins/index.ts +10 -23
  121. package/src/models/interfaces.ts +0 -79
  122. package/src/models/session.ts +4 -5
  123. package/src/server/index.ts +1 -1
  124. package/src/session/SessionClient.ts +53 -2
  125. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  126. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  127. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  128. package/src/session/__tests__/authStateStore.test.ts +209 -0
  129. package/src/session/__tests__/refresh.test.ts +256 -0
  130. package/src/session/authStateStore.ts +335 -0
  131. package/src/session/refresh.ts +334 -0
  132. package/src/shared/utils/debugUtils.ts +3 -3
  133. package/src/utils/__tests__/authWebUrl.test.ts +5 -40
  134. package/src/utils/__tests__/registrableApex.test.ts +62 -0
  135. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  136. package/src/utils/accountUtils.ts +2 -65
  137. package/src/utils/authWebUrl.ts +9 -39
  138. package/src/utils/coldBoot.ts +17 -14
  139. package/src/utils/platform.ts +21 -0
  140. package/src/utils/registrableApex.ts +45 -0
  141. package/src/utils/ssoBounce.ts +14 -393
  142. package/src/utils/validationUtils.ts +62 -0
  143. package/dist/cjs/AuthManager.js +0 -1110
  144. package/dist/cjs/AuthManagerTypes.js +0 -13
  145. package/dist/cjs/CrossDomainAuth.js +0 -206
  146. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  147. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  148. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  149. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  150. package/dist/cjs/utils/fapiAutoDetect.js +0 -99
  151. package/dist/cjs/utils/ssoEstablish.js +0 -110
  152. package/dist/cjs/utils/ssoReturn.js +0 -275
  153. package/dist/esm/AuthManager.js +0 -1105
  154. package/dist/esm/AuthManagerTypes.js +0 -12
  155. package/dist/esm/CrossDomainAuth.js +0 -201
  156. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  157. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  158. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  159. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  160. package/dist/esm/utils/fapiAutoDetect.js +0 -95
  161. package/dist/esm/utils/ssoEstablish.js +0 -107
  162. package/dist/esm/utils/ssoReturn.js +0 -271
  163. package/dist/types/AuthManager.d.ts +0 -380
  164. package/dist/types/AuthManagerTypes.d.ts +0 -81
  165. package/dist/types/CrossDomainAuth.d.ts +0 -164
  166. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  167. package/dist/types/mixins/OxyServices.redirect.d.ts +0 -92
  168. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  169. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  170. package/dist/types/utils/fapiAutoDetect.d.ts +0 -56
  171. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  172. package/dist/types/utils/ssoReturn.d.ts +0 -156
  173. package/src/AuthManager.ts +0 -1269
  174. package/src/AuthManagerTypes.ts +0 -86
  175. package/src/CrossDomainAuth.ts +0 -243
  176. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  177. package/src/__tests__/authManager.security.test.ts +0 -377
  178. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  179. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  180. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  181. package/src/mixins/OxyServices.redirect.ts +0 -122
  182. package/src/mixins/OxyServices.silent.ts +0 -272
  183. package/src/mixins/OxyServices.sso.ts +0 -261
  184. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  185. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  186. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  187. package/src/mixins/__tests__/silent.test.ts +0 -102
  188. package/src/mixins/__tests__/sso.test.ts +0 -228
  189. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  190. package/src/utils/__tests__/fapiAutoDetect.test.ts +0 -183
  191. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  192. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  193. package/src/utils/__tests__/ssoReturn.test.ts +0 -276
  194. package/src/utils/fapiAutoDetect.ts +0 -91
  195. package/src/utils/ssoEstablish.ts +0 -174
  196. package/src/utils/ssoReturn.ts +0 -389
@@ -1,183 +0,0 @@
1
- /**
2
- * Auto-detect of `auth.<rp-apex>` from `window.location` for the Clerk-style
3
- * multi-domain FAPI setup. The IdP backend independently derives `iss` and
4
- * the FedCM manifest from the request host, so the only contract the SDK
5
- * needs to honour is: build URLs against the same host the page is on,
6
- * one subdomain over.
7
- *
8
- * Ported verbatim from packages/auth-sdk/__tests__/utils/fapiAutoDetect.test.ts
9
- * plus Public Suffix List regression cases that are unique to the core
10
- * implementation's registrable-apex guard.
11
- */
12
-
13
- import { autoDetectAuthWebUrl, registrableApex } from '../fapiAutoDetect';
14
-
15
- function loc(hostname: string, protocol = 'https:'): Pick<Location, 'hostname' | 'protocol'> {
16
- return { hostname, protocol };
17
- }
18
-
19
- describe('autoDetectAuthWebUrl', () => {
20
- describe('returns auth.<apex> for public hostnames', () => {
21
- it('derives from the apex itself', () => {
22
- expect(autoDetectAuthWebUrl(loc('mention.earth'))).toBe('https://auth.mention.earth');
23
- });
24
-
25
- it('strips one leading subdomain', () => {
26
- expect(autoDetectAuthWebUrl(loc('www.mention.earth'))).toBe('https://auth.mention.earth');
27
- expect(autoDetectAuthWebUrl(loc('app.alia.onl'))).toBe('https://auth.alia.onl');
28
- });
29
-
30
- it('strips multiple leading subdomains down to the last two labels', () => {
31
- // The heuristic is "last two labels" — fine for our use case because
32
- // the IdP itself validates the request host. Deeply-nested hostnames
33
- // resolve to the trailing two-label apex.
34
- expect(autoDetectAuthWebUrl(loc('deep.app.homiio.com'))).toBe('https://auth.homiio.com');
35
- });
36
-
37
- it('honours the request protocol so dev http stays http', () => {
38
- expect(autoDetectAuthWebUrl(loc('staging.example.test', 'http:'))).toBe(
39
- 'http://auth.example.test'
40
- );
41
- });
42
- });
43
-
44
- describe('returns current origin when already on the IdP', () => {
45
- it('keeps everything same-origin instead of hopping to a sibling IdP', () => {
46
- expect(autoDetectAuthWebUrl(loc('auth.mention.earth'))).toBe('https://auth.mention.earth');
47
- expect(autoDetectAuthWebUrl(loc('auth.oxy.so'))).toBe('https://auth.oxy.so');
48
- });
49
- });
50
-
51
- describe('returns undefined where auto-detect would be wrong', () => {
52
- it('skips localhost and 127.0.0.1 (dev)', () => {
53
- expect(autoDetectAuthWebUrl(loc('localhost', 'http:'))).toBeUndefined();
54
- expect(autoDetectAuthWebUrl(loc('127.0.0.1', 'http:'))).toBeUndefined();
55
- });
56
-
57
- it('skips IPv4 literals', () => {
58
- expect(autoDetectAuthWebUrl(loc('192.168.1.10'))).toBeUndefined();
59
- expect(autoDetectAuthWebUrl(loc('10.0.0.1'))).toBeUndefined();
60
- });
61
-
62
- it('skips IPv6 literals (bracketed)', () => {
63
- expect(autoDetectAuthWebUrl(loc('[::1]'))).toBeUndefined();
64
- });
65
-
66
- it('skips single-label hostnames', () => {
67
- expect(autoDetectAuthWebUrl(loc('intranet'))).toBeUndefined();
68
- });
69
-
70
- it('skips unknown protocols', () => {
71
- expect(autoDetectAuthWebUrl({ hostname: 'mention.earth', protocol: 'file:' })).toBeUndefined();
72
- expect(autoDetectAuthWebUrl({ hostname: 'mention.earth', protocol: 'ftp:' })).toBeUndefined();
73
- });
74
-
75
- it('skips empty/missing hostnames', () => {
76
- expect(autoDetectAuthWebUrl(loc(''))).toBeUndefined();
77
- });
78
-
79
- it('returns undefined when no location is available (SSR / non-browser)', () => {
80
- expect(autoDetectAuthWebUrl(undefined)).toBeUndefined();
81
- });
82
- });
83
-
84
- describe('uses the Public Suffix List for registrable apex detection', () => {
85
- it('derives from the registrable domain under multi-part public suffixes', () => {
86
- expect(autoDetectAuthWebUrl(loc('foo.co.uk'))).toBe('https://auth.foo.co.uk');
87
- expect(autoDetectAuthWebUrl(loc('shop.com.au'))).toBe('https://auth.shop.com.au');
88
- expect(autoDetectAuthWebUrl(loc('shop.victim.com.tr'))).toBe('https://auth.victim.com.tr');
89
- });
90
-
91
- it('derives from the registrable domain under private hosted suffixes', () => {
92
- expect(autoDetectAuthWebUrl(loc('honest.github.io'))).toBe('https://auth.honest.github.io');
93
- expect(autoDetectAuthWebUrl(loc('app.victim.pages.dev'))).toBe(
94
- 'https://auth.victim.pages.dev'
95
- );
96
- expect(autoDetectAuthWebUrl(loc('site.victim.netlify.app'))).toBe(
97
- 'https://auth.victim.netlify.app'
98
- );
99
- });
100
-
101
- it('returns undefined for bare public and private suffixes', () => {
102
- expect(autoDetectAuthWebUrl(loc('co.uk'))).toBeUndefined();
103
- expect(autoDetectAuthWebUrl(loc('github.io'))).toBeUndefined();
104
- expect(autoDetectAuthWebUrl(loc('pages.dev'))).toBeUndefined();
105
- expect(autoDetectAuthWebUrl(loc('netlify.app'))).toBeUndefined();
106
- });
107
- });
108
-
109
- // Regression guard: the refactor to delegate host handling to `registrableApex`
110
- // must not change any of the pre-existing return values.
111
- describe('regression — unchanged values after registrableApex extraction', () => {
112
- const cases: Array<[string, string | undefined]> = [
113
- ['mention.earth', 'https://auth.mention.earth'],
114
- ['www.mention.earth', 'https://auth.mention.earth'],
115
- ['deep.app.homiio.com', 'https://auth.homiio.com'],
116
- ['auth.oxy.so', 'https://auth.oxy.so'],
117
- ['auth.mention.earth', 'https://auth.mention.earth'],
118
- ['localhost', undefined],
119
- ['192.168.1.10', undefined],
120
- ['[::1]', undefined],
121
- ['intranet', undefined],
122
- ['', undefined],
123
- ['foo.co.uk', 'https://auth.foo.co.uk'],
124
- ];
125
- it.each(cases)('autoDetectAuthWebUrl(%s) === %s', (hostname, expected) => {
126
- const protocol = hostname === 'localhost' || hostname === '[::1]' ? 'http:' : 'https:';
127
- expect(autoDetectAuthWebUrl(loc(hostname, protocol))).toBe(expected);
128
- });
129
- });
130
- });
131
-
132
- describe('registrableApex', () => {
133
- it('returns the eTLD+1 for a normal two-label host', () => {
134
- expect(registrableApex('mention.earth')).toBe('mention.earth');
135
- });
136
-
137
- it('strips subdomains down to the trailing two labels', () => {
138
- expect(registrableApex('www.mention.earth')).toBe('mention.earth');
139
- expect(registrableApex('deep.app.homiio.com')).toBe('homiio.com');
140
- });
141
-
142
- it('lower-cases the input', () => {
143
- expect(registrableApex('WWW.Mention.EARTH')).toBe('mention.earth');
144
- });
145
-
146
- it('returns the eTLD+1 for hosts under multi-part public suffixes', () => {
147
- expect(registrableApex('foo.co.uk')).toBe('foo.co.uk');
148
- expect(registrableApex('www.foo.co.uk')).toBe('foo.co.uk');
149
- expect(registrableApex('shop.victim.com.tr')).toBe('victim.com.tr');
150
- });
151
-
152
- it('returns the eTLD+1 for hosts under private hosted suffixes', () => {
153
- expect(registrableApex('honest.github.io')).toBe('honest.github.io');
154
- expect(registrableApex('app.victim.pages.dev')).toBe('victim.pages.dev');
155
- expect(registrableApex('site.victim.netlify.app')).toBe('victim.netlify.app');
156
- });
157
-
158
- it('returns null for bare public and private suffixes', () => {
159
- expect(registrableApex('co.uk')).toBeNull();
160
- expect(registrableApex('github.io')).toBeNull();
161
- expect(registrableApex('pages.dev')).toBeNull();
162
- expect(registrableApex('netlify.app')).toBeNull();
163
- });
164
-
165
- it('returns null for IPv4 literals', () => {
166
- expect(registrableApex('192.168.1.10')).toBeNull();
167
- expect(registrableApex('10.0.0.1')).toBeNull();
168
- });
169
-
170
- it('returns null for IPv6 literals and hosts carrying a port', () => {
171
- expect(registrableApex('[::1]')).toBeNull();
172
- expect(registrableApex('mention.earth:3000')).toBeNull();
173
- });
174
-
175
- it('returns null for single-label hosts', () => {
176
- expect(registrableApex('intranet')).toBeNull();
177
- expect(registrableApex('localhost')).toBeNull();
178
- });
179
-
180
- it('returns null for empty input', () => {
181
- expect(registrableApex('')).toBeNull();
182
- });
183
- });
@@ -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
- });