@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,821 +0,0 @@
1
- import { OxyAuthenticationError } from '../OxyServices.errors.js';
2
- import { createDebugLogger } from '../shared/utils/debugUtils.js';
3
- import { normalizeUserIdentity } from '../utils/userIdentity.js';
4
- const debug = createDebugLogger('FedCM');
5
- // Modern (W3C spec) → legacy (Chrome 125–131) mode value mapping. Used to
6
- // retry a credential request when an older browser rejects the modern enum.
7
- const MODERN_TO_LEGACY_MODE = {
8
- active: 'button',
9
- passive: 'widget',
10
- };
11
- // Legacy → modern mapping so callers may pass either spelling.
12
- const LEGACY_TO_MODERN_MODE = {
13
- button: 'active',
14
- widget: 'passive',
15
- };
16
- /**
17
- * Normalise any accepted mode value to the modern W3C spelling
18
- * (`'active'`/`'passive'`), which is what is sent to the browser first.
19
- */
20
- function toModernMode(mode) {
21
- return mode === 'button' || mode === 'widget' ? LEGACY_TO_MODERN_MODE[mode] : mode;
22
- }
23
- /**
24
- * Detect the synchronous `TypeError` a pre-spec browser throws when it does not
25
- * recognise a modern `mode` enum value (e.g. Chrome 125–131 rejecting
26
- * `'active'`/`'passive'`). Such a browser only understands the legacy
27
- * `'button'`/`'widget'` values, so the caller can retry with those.
28
- */
29
- function isUnknownModeEnumError(error) {
30
- if (!(error instanceof TypeError))
31
- return false;
32
- const message = error.message.toLowerCase();
33
- return (message.includes('identitycredentialrequestoptionsmode') ||
34
- ((message.includes('active') || message.includes('passive')) &&
35
- (message.includes('enum') || message.includes('not a valid'))));
36
- }
37
- /**
38
- * Detect a `navigator.credentials.get` rejection that is consistent with
39
- * "the supplied loginHint matched no account at the IdP".
40
- *
41
- * When an RP passes a `loginHint` and the IdP returns accounts but NONE of them
42
- * declare that hint in their `login_hints`, Chrome filters every account out,
43
- * greys it in the chooser ("You can't sign in using this account"), logs
44
- * "Accounts were received, but none matched the login hint…", and ultimately
45
- * rejects the credential request — surfacing as a `NotAllowedError` /
46
- * `AbortError` (the same shape as a user-cancelled or timed-out request). A
47
- * stale hint left over from a previously-signed-in/test account therefore hard
48
- * -blocks sign-in.
49
- *
50
- * We can only safely apply the clear-and-retry recovery when a `loginHint` was
51
- * actually supplied; without one this is just a normal cancel/timeout and must
52
- * NOT be retried. Callers gate on `hadLoginHint` before calling this.
53
- */
54
- function isPossibleHintMismatchError(error) {
55
- if (!(error instanceof Error))
56
- return false;
57
- // FedCM surfaces a filtered-out / no-eligible-account outcome as
58
- // NotAllowedError (current Chrome) or AbortError (our own timeout abort while
59
- // the chooser had no selectable account). Both are indistinguishable from a
60
- // genuine user cancel at the API level, so the gate on "a hint was supplied"
61
- // (in the caller) is what makes the retry safe and targeted.
62
- return error.name === 'NotAllowedError' || error.name === 'AbortError';
63
- }
64
- const FEDCM_LOGIN_HINT_KEY = 'oxy_fedcm_login_hint';
65
- // Global lock to prevent concurrent FedCM requests
66
- // FedCM only allows one navigator.credentials.get request at a time
67
- let fedCMRequestInProgress = false;
68
- let fedCMRequestPromise = null;
69
- let currentMediationMode = null;
70
- // AbortController of the in-flight request, exposed at module scope so an
71
- // arriving INTERACTIVE request can abort a slow/hung SILENT one instead of
72
- // blocking on it (see requestIdentityCredential). Set when a request starts,
73
- // cleared in that request's `finally`.
74
- let fedCMActiveController = null;
75
- /**
76
- * Federated Credential Management (FedCM) Authentication Mixin
77
- *
78
- * Implements the modern browser-native identity federation API that enables
79
- * Google-style cross-domain authentication without third-party cookies.
80
- *
81
- * Browser Support:
82
- * - Chrome 108+
83
- * - Safari 16.4+
84
- * - Edge 108+
85
- * - Firefox: Not yet supported (fallback required)
86
- *
87
- * Key Features:
88
- * - No redirects or secondary windows required
89
- * - Browser-native UI prompts
90
- * - Privacy-preserving (IdP can't track users)
91
- * - Automatic SSO across domains
92
- * - Silent re-authentication support
93
- *
94
- * @see https://developer.mozilla.org/en-US/docs/Web/API/FedCM_API
95
- */
96
- export function OxyServicesFedCMMixin(Base) {
97
- var _a;
98
- return _a = class extends Base {
99
- constructor(...args) {
100
- super(...args);
101
- }
102
- resolveFedcmConfigUrl() {
103
- // `DEFAULT_CONFIG_URL` is a static on the composed class; read it off the
104
- // most-derived constructor through a typed cast (not `any`).
105
- const configCtor = this.constructor;
106
- return this.config.authWebUrl
107
- ? `${this.config.authWebUrl}/fedcm.json`
108
- : configCtor.DEFAULT_CONFIG_URL;
109
- }
110
- /**
111
- * Check if FedCM is supported in the current browser
112
- */
113
- static isFedCMSupported() {
114
- if (typeof window === 'undefined')
115
- return false;
116
- return 'IdentityCredential' in window && 'navigator' in window && 'credentials' in navigator;
117
- }
118
- /**
119
- * Instance method to check FedCM support
120
- */
121
- isFedCMSupported() {
122
- return this.constructor.isFedCMSupported();
123
- }
124
- /**
125
- * Sign in using FedCM (Federated Credential Management API)
126
- *
127
- * This provides a Google-style authentication experience:
128
- * - Browser shows native "Sign in with Oxy" prompt
129
- * - No redirect or secondary window required
130
- * - User approves → credential exchange happens in browser
131
- * - All apps automatically get SSO after first sign-in
132
- *
133
- * @param options - Authentication options
134
- * @returns Session with access token and user data
135
- * @throws {OxyAuthenticationError} If FedCM not supported or user cancels
136
- *
137
- * @example
138
- * ```typescript
139
- * try {
140
- * const session = await oxyServices.signInWithFedCM();
141
- * const user = session.user;
142
- * } catch (error) {
143
- * // Fallback to redirect auth
144
- * oxyServices.signInWithRedirect();
145
- * }
146
- * ```
147
- */
148
- async signInWithFedCM(options = {}) {
149
- if (!this.isFedCMSupported()) {
150
- throw new OxyAuthenticationError('FedCM not supported in this browser. Please update your browser or use an alternative sign-in method.');
151
- }
152
- // Use provided loginHint, or fall back to stored last-used account ID.
153
- const initialLoginHint = options.loginHint || this.getStoredLoginHint();
154
- try {
155
- return await this.attemptInteractiveSignIn(options, initialLoginHint);
156
- }
157
- catch (error) {
158
- // A STALE loginHint (e.g. left over from a previously-signed-in or test
159
- // account) that matches no account at the IdP makes Chrome filter out
160
- // every account and reject the request — indistinguishable from a user
161
- // cancel. When that happens AND we supplied a hint, clear the bad hint
162
- // and retry the credential request ONCE with no hint, which lets the
163
- // chooser surface the genuinely available account(s). We only do this for
164
- // a hint we pulled from storage (not a caller-supplied one), and only
165
- // once, so a real cancel never loops.
166
- const usedStoredHint = !!initialLoginHint && !options.loginHint;
167
- if (usedStoredHint && isPossibleHintMismatchError(error)) {
168
- debug.log('Interactive sign-in: stored loginHint matched no account; clearing it and retrying without a hint');
169
- this.clearLoginHint();
170
- return await this.attemptInteractiveSignIn(options, undefined);
171
- }
172
- throw this.normalizeInteractiveSignInError(error);
173
- }
174
- }
175
- /**
176
- * Run a single interactive FedCM credential request + token exchange for the
177
- * given (possibly undefined) loginHint. A successful exchange plants the
178
- * access token and persists the user id as the future loginHint — the hint is
179
- * therefore only ever stored after a GENUINELY successful sign-in, never
180
- * speculatively.
181
- *
182
- * @private
183
- */
184
- async attemptInteractiveSignIn(options, loginHint) {
185
- // Prefer a server-minted, origin-bound nonce so the downstream
186
- // `/fedcm/exchange` can validate it. A caller-supplied nonce is
187
- // respected as-is for advanced use cases.
188
- const nonce = options.nonce || (await this.getFedcmNonce());
189
- const clientId = this.getClientId();
190
- debug.log('Interactive sign-in: Requesting credential for', clientId, loginHint ? `(hint: ${loginHint})` : '');
191
- // Request credential from browser's native identity flow.
192
- // mode: 'active' signals this is a user-gesture-initiated (button) flow.
193
- // 'active' is the current W3C spec value; requestIdentityCredential
194
- // transparently retries with the legacy 'button' value for Chrome 125–131.
195
- const credential = await this.requestIdentityCredential({
196
- configURL: this.resolveFedcmConfigUrl(),
197
- clientId,
198
- nonce,
199
- context: options.context,
200
- loginHint,
201
- mode: 'active',
202
- });
203
- if (!credential || !credential.token) {
204
- throw new OxyAuthenticationError('No credential received from browser');
205
- }
206
- debug.log('Interactive sign-in: Got credential, exchanging for session');
207
- // Exchange FedCM ID token for Oxy session
208
- const session = await this.exchangeIdTokenForSession(credential.token);
209
- // Store the access token in HttpService. Refresh stays in the httpOnly
210
- // cookie slot set by the API.
211
- if (session?.accessToken) {
212
- this.httpService.setTokens(session.accessToken);
213
- }
214
- // Store the user ID as loginHint for future FedCM requests — only now, after
215
- // a real successful exchange, so we never persist a hint that cannot resolve.
216
- if (session?.user?.id) {
217
- this.storeLoginHint(session.user.id);
218
- }
219
- debug.log('Interactive sign-in: Success!', { userId: session?.user?.id });
220
- return session;
221
- }
222
- /**
223
- * Map a raw FedCM/exchange failure to a user-facing {@link OxyAuthenticationError}
224
- * (or pass it through). Extracted so the clear-and-retry path can reuse the
225
- * exact same error normalisation as the first attempt.
226
- *
227
- * @private
228
- */
229
- normalizeInteractiveSignInError(error) {
230
- debug.log('Interactive sign-in failed:', error);
231
- const errorMessage = error instanceof Error ? error.message : String(error);
232
- // FedCM aborts/network failures surface as DOMException/Error instances,
233
- // both of which carry a `name`. Anything else has no meaningful name.
234
- const errorName = error instanceof Error ? error.name : '';
235
- if (errorName === 'AbortError') {
236
- return new OxyAuthenticationError('Sign-in was cancelled by user');
237
- }
238
- if (errorName === 'NetworkError') {
239
- return new OxyAuthenticationError('Network error during sign-in. Please check your connection.');
240
- }
241
- if (errorMessage.includes('multiple accounts')) {
242
- return new OxyAuthenticationError('Please sign out and sign in again to use FedCM with a single account');
243
- }
244
- if (errorMessage.includes('retrieving a token') || errorMessage.includes('Error retrieving')) {
245
- debug.error('FedCM token retrieval error - this may be a browser or IdP configuration issue');
246
- return new OxyAuthenticationError('Authentication failed. Please try again or use an alternative sign-in method.');
247
- }
248
- return error;
249
- }
250
- /**
251
- * Silent sign-in using FedCM
252
- *
253
- * Attempts to automatically re-authenticate the user without any UI.
254
- * This is what enables "instant sign-in" across all Oxy domains after
255
- * the user has signed in once.
256
- *
257
- * The browser will:
258
- * 1. Check if user has previously signed in to Oxy
259
- * 2. Check if user is still signed in at auth.oxy.so
260
- * 3. If yes, automatically provide credential without prompting
261
- *
262
- * @returns Session if user is already signed in, null otherwise
263
- *
264
- * @example
265
- * ```typescript
266
- * // On app startup
267
- * useEffect(() => {
268
- * const checkAuth = async () => {
269
- * const session = await oxyServices.silentSignInWithFedCM();
270
- * if (session) {
271
- * setUser(session.user);
272
- * } else {
273
- * // Show sign-in button
274
- * }
275
- * };
276
- * checkAuth();
277
- * }, []);
278
- * ```
279
- */
280
- async silentSignInWithFedCM() {
281
- if (!this.isFedCMSupported()) {
282
- debug.log('Silent SSO: FedCM not supported in this browser');
283
- return null;
284
- }
285
- const clientId = this.getClientId();
286
- debug.log('Silent SSO: Starting for', clientId);
287
- // Only try silent mediation (no UI) - works if user previously consented.
288
- // We intentionally do NOT fall back to optional mediation here because
289
- // this runs on app startup — showing browser UI without user action is bad UX.
290
- // Optional/interactive mediation should only happen when the user clicks "Sign In".
291
- let credential = null;
292
- const loginHint = this.getStoredLoginHint();
293
- // Fast-skip: with no stored login hint this browser has never completed a
294
- // FedCM sign-in for any Oxy account, so silent mediation cannot return a
295
- // credential — the IdP has nothing to silently re-issue. Doing the full
296
- // round-trip anyway (mint a nonce via `POST /fedcm/nonce`, then a
297
- // `navigator.credentials.get` that aborts after `FEDCM_SILENT_TIMEOUT`) is
298
- // pure latency in the cold-boot critical path. Return `null` immediately so
299
- // the next cold-boot step (stored-session / iframe / bounce) runs without
300
- // the wasted nonce mint + abort wait. A genuinely associated browser always
301
- // has a hint (it is stored only after a real exchange), so this never skips
302
- // a recoverable session.
303
- if (!loginHint) {
304
- debug.log('Silent SSO: No stored login hint — skipping silent mediation (no association on this browser)');
305
- return null;
306
- }
307
- try {
308
- // Server-minted, origin-bound nonce — required for `/fedcm/exchange`
309
- // to accept the resulting ID token (anti-replay binding).
310
- const nonce = await this.getFedcmNonce();
311
- debug.log('Silent SSO: Attempting silent mediation...', loginHint ? `(hint: ${loginHint})` : '');
312
- credential = await this.requestIdentityCredential({
313
- configURL: this.resolveFedcmConfigUrl(),
314
- clientId,
315
- nonce,
316
- loginHint,
317
- mediation: 'silent',
318
- });
319
- debug.log('Silent SSO: Silent mediation result:', { hasCredential: !!credential, hasToken: !!credential?.token });
320
- }
321
- catch (silentError) {
322
- const errorName = silentError instanceof Error ? silentError.name : 'Unknown';
323
- const errorMessage = silentError instanceof Error ? silentError.message : String(silentError);
324
- // Handle specific FedCM errors with better logging
325
- if (errorMessage.includes('multiple accounts')) {
326
- debug.log('Silent SSO: User has used multiple accounts - silent mediation not available');
327
- debug.log('Silent SSO: User needs to explicitly sign in to choose account');
328
- }
329
- else if (errorMessage.includes('conditions')) {
330
- debug.log('Silent SSO: Conditions not met (user may not be logged in at IdP or not in approved_clients)');
331
- }
332
- else {
333
- debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
334
- }
335
- return null;
336
- }
337
- if (!credential || !credential.token) {
338
- debug.log('Silent SSO: No credential returned (user not logged in at IdP or hasn\'t consented)');
339
- return null;
340
- }
341
- debug.log('Silent SSO: Got credential, exchanging for session...');
342
- let session;
343
- try {
344
- session = await this.exchangeIdTokenForSession(credential.token);
345
- }
346
- catch (exchangeError) {
347
- debug.error('Silent SSO: Token exchange failed:', exchangeError);
348
- return null;
349
- }
350
- // Validate session response has required fields
351
- if (!session) {
352
- debug.error('Silent SSO: Exchange returned null session');
353
- return null;
354
- }
355
- if (!session.sessionId) {
356
- debug.error('Silent SSO: Exchange returned session without sessionId:', session);
357
- return null;
358
- }
359
- if (!session.user) {
360
- debug.error('Silent SSO: Exchange returned session without user:', session);
361
- return null;
362
- }
363
- // Store the access token. Refresh stays in the httpOnly cookie slot set by
364
- // the API.
365
- if (session.accessToken) {
366
- this.httpService.setTokens(session.accessToken);
367
- debug.log('Silent SSO: Access token set');
368
- }
369
- else {
370
- debug.warn('Silent SSO: No accessToken in session response');
371
- }
372
- // Store the user ID as loginHint for future FedCM requests
373
- if (session.user?.id) {
374
- this.storeLoginHint(session.user.id);
375
- }
376
- debug.log('Silent SSO: Success!', {
377
- sessionId: session.sessionId?.substring(0, 8) + '...',
378
- userId: session.user?.id
379
- });
380
- return session;
381
- }
382
- /**
383
- * Request identity credential from browser using FedCM API
384
- *
385
- * Uses a global lock to prevent concurrent requests, as FedCM only
386
- * allows one navigator.credentials.get request at a time.
387
- *
388
- * Interactive requests (optional/required) wait for any silent request to finish first.
389
- *
390
- * @private
391
- */
392
- async requestIdentityCredential(options) {
393
- const requestedMediation = options.mediation || 'optional';
394
- const isInteractive = requestedMediation !== 'silent';
395
- debug.log('requestIdentityCredential called:', {
396
- mediation: requestedMediation,
397
- clientId: options.clientId,
398
- inProgress: fedCMRequestInProgress,
399
- });
400
- // If a request is already in progress...
401
- if (fedCMRequestInProgress && fedCMRequestPromise) {
402
- debug.log('Request already in progress, waiting...');
403
- // If the in-flight request is SILENT and this new one is INTERACTIVE,
404
- // abort the silent and proceed immediately. The silent round-trip can be
405
- // slow (it runs on page load and may stall in the browser), and a user who
406
- // just clicked "Sign In" must never be made to wait on — or be blocked by —
407
- // it. Awaiting the silent here is what previously let a hung silent
408
- // request deadlock the sign-in button, so we deliberately do NOT await it:
409
- // we abort it (its own `finally` resets the lock as it settles) and fall
410
- // through to start the interactive request synchronously below.
411
- if (currentMediationMode === 'silent' && isInteractive) {
412
- debug.log('Aborting in-flight silent request to make way for interactive request');
413
- fedCMActiveController?.abort();
414
- // Fall through. The interactive request synchronously overwrites the
415
- // lock globals (below); the aborted silent's `finally` uses identity
416
- // guards so it cannot later clobber this interactive request's state.
417
- }
418
- else {
419
- // Same type of request - wait for the existing one
420
- try {
421
- return await fedCMRequestPromise;
422
- }
423
- catch {
424
- return null;
425
- }
426
- }
427
- }
428
- fedCMRequestInProgress = true;
429
- currentMediationMode = requestedMediation;
430
- const controller = new AbortController();
431
- fedCMActiveController = controller;
432
- // Use shorter timeout for silent mediation since it should be quick.
433
- // The timeout constants are static on the composed class; read them off the
434
- // most-derived constructor through a typed cast (not `any`).
435
- const timeoutCtor = this.constructor;
436
- const timeoutMs = requestedMediation === 'silent'
437
- ? timeoutCtor.FEDCM_SILENT_TIMEOUT
438
- : timeoutCtor.FEDCM_TIMEOUT;
439
- const timeout = setTimeout(() => {
440
- debug.log('Request timed out after', timeoutMs, 'ms (mediation:', requestedMediation + ')');
441
- controller.abort();
442
- }, timeoutMs);
443
- // Hard settle guarantee for the timeout path.
444
- //
445
- // The `setTimeout` above aborts the request's `AbortController`, which is
446
- // the COOPERATIVE cancel signal. For a regular `fetch` an abort deterministically
447
- // rejects the awaited promise — but `navigator.credentials.get()` is a
448
- // browser-internal FedCM primitive whose abort behaviour is NOT guaranteed
449
- // to settle the awaited promise in every Chrome version / internal state
450
- // (the credential request can sit "pending" while the browser-side flow is
451
- // stuck, ignoring the signal). If that happens, `await credentials.get(...)`
452
- // never resolves OR rejects, this IIFE hangs forever, and — because this is
453
- // ONE step of the ordered cold-boot sequence — the whole cold boot hangs and
454
- // the terminal `/sso` bounce never fires. That was the production hang.
455
- //
456
- // `settlePromise` races the credential lookup against a timer that ALWAYS
457
- // resolves to `null` shortly after the abort deadline. The abort still fires
458
- // first (so the browser is asked to cancel), but even if `credentials.get`
459
- // never settles, the race resolves and the step falls through cleanly to the
460
- // next cold-boot step. The small `FEDCM_ABORT_SETTLE_GRACE_MS` margin gives a
461
- // well-behaved browser the chance to surface its own AbortError (preserving
462
- // the existing error path) before we force a clean `null`.
463
- let settleTimer;
464
- const settlePromise = new Promise((resolve) => {
465
- const ctor = this.constructor;
466
- settleTimer = setTimeout(() => {
467
- debug.log('Request hard-settled to null', timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS, 'ms (credentials.get never settled after abort)');
468
- resolve(null);
469
- }, timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS);
470
- });
471
- // Normalise the caller's mode to the modern W3C value first. A modern
472
- // browser accepts it; an older one (Chrome 125–131) rejects it with a
473
- // synchronous TypeError, in which case we retry with the legacy value.
474
- const modernMode = options.mode ? toModernMode(options.mode) : undefined;
475
- // Build the identity request for a specific mode value. The `mode` field
476
- // lives on the `identity` object (sibling of `providers`), separate from
477
- // the top-level `mediation` field.
478
- const buildCredentialOptions = (modeValue) => ({
479
- identity: {
480
- providers: [
481
- {
482
- configURL: options.configURL,
483
- clientId: options.clientId,
484
- // Older browsers read `nonce` at the top level; Chrome 145+
485
- // expects it inside `params`. Send both for full coverage.
486
- nonce: options.nonce,
487
- params: {
488
- nonce: options.nonce,
489
- },
490
- ...(options.loginHint && { loginHint: options.loginHint }),
491
- },
492
- ],
493
- ...(modeValue && { mode: modeValue }),
494
- },
495
- mediation: requestedMediation,
496
- signal: controller.signal,
497
- });
498
- // The DOM lib's `CredentialsContainer` does not declare the FedCM `identity`
499
- // request in every TypeScript version we build against. Re-type through the
500
- // minimal structural interface above (not `any`) to keep this typed.
501
- const credentials = navigator.credentials;
502
- fedCMRequestPromise = (async () => {
503
- try {
504
- debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
505
- let credential;
506
- try {
507
- // Race the browser FedCM lookup against the hard settle guarantee so
508
- // a `credentials.get` that ignores the abort signal can never hang
509
- // the cold boot (see `settlePromise`).
510
- credential = await Promise.race([
511
- credentials.get(buildCredentialOptions(modernMode)),
512
- settlePromise,
513
- ]);
514
- }
515
- catch (modeError) {
516
- // Chrome 125–131 only knows the legacy 'button'/'widget' enum and
517
- // throws a synchronous TypeError for the modern 'active'/'passive'
518
- // values. Retry once with the legacy value so older browsers work.
519
- if (modernMode && isUnknownModeEnumError(modeError)) {
520
- const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
521
- debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
522
- credential = await Promise.race([
523
- credentials.get(buildCredentialOptions(legacyMode)),
524
- settlePromise,
525
- ]);
526
- }
527
- else {
528
- throw modeError;
529
- }
530
- }
531
- debug.log('navigator.credentials.get returned:', {
532
- hasCredential: !!credential,
533
- type: credential?.type,
534
- hasToken: !!credential?.token,
535
- });
536
- if (!credential || credential.type !== 'identity' || !credential.token) {
537
- debug.log('No valid identity credential returned');
538
- return null;
539
- }
540
- const isAutoSelected = !!credential.isAutoSelected;
541
- debug.log('Got valid identity credential with token', { isAutoSelected });
542
- return { token: credential.token, isAutoSelected };
543
- }
544
- catch (error) {
545
- const errorName = error instanceof Error ? error.name : 'Unknown';
546
- const errorMessage = error instanceof Error ? error.message : String(error);
547
- debug.log('navigator.credentials.get error:', { name: errorName, message: errorMessage });
548
- throw error;
549
- }
550
- finally {
551
- clearTimeout(timeout);
552
- if (settleTimer !== undefined) {
553
- clearTimeout(settleTimer);
554
- }
555
- // Only reset the shared lock if it still belongs to THIS request. When an
556
- // interactive request aborts a slow silent one, the silent settles (and
557
- // runs this `finally`) AFTER the interactive has already taken over the
558
- // lock and installed its own controller/promise. Guarding on identity
559
- // (`fedCMActiveController === controller`) ensures the settling silent
560
- // cannot null out the interactive request's in-progress state. The
561
- // request that still owns the lock clears it; the superseded one is a
562
- // no-op here.
563
- if (fedCMActiveController === controller) {
564
- fedCMRequestInProgress = false;
565
- fedCMRequestPromise = null;
566
- currentMediationMode = null;
567
- fedCMActiveController = null;
568
- }
569
- }
570
- })();
571
- return fedCMRequestPromise;
572
- }
573
- /**
574
- * Exchange FedCM ID token for Oxy session
575
- *
576
- * The ID token is a JWT issued by auth.oxy.so that proves the user's
577
- * identity. We exchange it for a full Oxy session with access token.
578
- *
579
- * @private
580
- */
581
- async exchangeIdTokenForSession(idToken) {
582
- debug.log('Exchanging ID token for session...');
583
- try {
584
- const response = await this.makeRequest('POST', '/fedcm/exchange', { id_token: idToken }, { cache: false });
585
- debug.log('Token exchange complete:', {
586
- hasSession: !!response?.sessionId,
587
- hasUser: !!response?.user,
588
- });
589
- return {
590
- ...response,
591
- user: normalizeUserIdentity(response.user),
592
- };
593
- }
594
- catch (error) {
595
- debug.error('Token exchange failed:', error instanceof Error ? error.message : String(error));
596
- throw error;
597
- }
598
- }
599
- /**
600
- * Revoke FedCM credential (sign out)
601
- *
602
- * Uses IdentityCredential.disconnect() to tell the browser to forget
603
- * the RP-IdP-account association. This resets the "returning account"
604
- * state, which is required for silent mediation to work again.
605
- */
606
- async revokeFedCMCredential() {
607
- // Read hint before clearing so we can pass it to disconnect()
608
- const accountHint = this.getStoredLoginHint();
609
- this.clearLoginHint();
610
- if (!this.isFedCMSupported()) {
611
- return;
612
- }
613
- try {
614
- // The DOM lib does not declare the global `IdentityCredential` interface
615
- // object (with its static `disconnect`) in every TypeScript version we
616
- // build against. Read it off `window` through the minimal structural type
617
- // (not `any`), guarding that `disconnect` is actually present at runtime.
618
- const fedCMWindow = window;
619
- const identityCredential = fedCMWindow.IdentityCredential;
620
- if (identityCredential && typeof identityCredential.disconnect === 'function') {
621
- const clientId = this.getClientId();
622
- await identityCredential.disconnect({
623
- configURL: this.resolveFedcmConfigUrl(),
624
- clientId,
625
- accountHint: accountHint || '*',
626
- });
627
- debug.log('FedCM credential disconnected');
628
- }
629
- }
630
- catch (error) {
631
- debug.log('FedCM disconnect failed (non-critical):', error instanceof Error ? error.message : String(error));
632
- }
633
- }
634
- /**
635
- * Get configuration for FedCM
636
- *
637
- * @returns FedCM configuration with browser support info
638
- */
639
- getFedCMConfig() {
640
- return {
641
- enabled: this.isFedCMSupported(),
642
- configURL: this.resolveFedcmConfigUrl(),
643
- clientId: this.getClientId(),
644
- };
645
- }
646
- /**
647
- * Generate a cryptographically secure local nonce for FedCM.
648
- *
649
- * NOTE: this is a *local* fallback only. The server-side `/fedcm/exchange`
650
- * endpoint requires the nonce embedded in the ID token to have been minted
651
- * by `POST /fedcm/nonce` (see {@link mintServerNonce}) and bound to this
652
- * origin. A purely local nonce will be rejected with `invalid_nonce`. Use
653
- * {@link getFedcmNonce}, which prefers a server-minted nonce and only falls
654
- * back to this generator when the mint endpoint is unreachable.
655
- *
656
- * @private
657
- */
658
- generateNonce() {
659
- if (typeof crypto !== 'undefined' && crypto.randomUUID) {
660
- return crypto.randomUUID();
661
- }
662
- if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
663
- const bytes = new Uint8Array(16);
664
- crypto.getRandomValues(bytes);
665
- return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
666
- }
667
- throw new Error('No secure random source available for nonce generation');
668
- }
669
- /**
670
- * Mint a single-use, origin-bound nonce from the Oxy API.
671
- *
672
- * The FedCM ID token issued by the IdP embeds this nonce as the `nonce`
673
- * claim. When the consuming app calls `POST /fedcm/exchange`, the API burns
674
- * the nonce (atomic `usedAt` transition) and verifies it was minted for the
675
- * same origin as the token `aud`. This is the anti-replay binding required
676
- * by the API's H9 hardening — without a server-minted nonce the exchange
677
- * always fails.
678
- *
679
- * The browser attaches the `Origin` header automatically on this
680
- * cross-origin request, so the API binds the nonce to the calling app's
681
- * origin (which also becomes the FedCM `clientId`/token `aud`).
682
- *
683
- * @private
684
- */
685
- async mintServerNonce() {
686
- const result = await this.makeRequest('POST', '/fedcm/nonce', {}, { cache: false });
687
- if (!result?.nonce) {
688
- throw new OxyAuthenticationError('FedCM nonce endpoint returned no nonce');
689
- }
690
- return result.nonce;
691
- }
692
- /**
693
- * Resolve the nonce to use for a FedCM credential request.
694
- *
695
- * Prefers a server-minted, origin-bound nonce (required for the token
696
- * exchange to succeed). If the mint endpoint is unreachable we fall back to
697
- * a locally generated nonce so the browser flow can still proceed; the
698
- * exchange may then fail server-side, but that is strictly better than
699
- * throwing before the browser ever shows its UI.
700
- *
701
- * @private
702
- */
703
- async getFedcmNonce() {
704
- try {
705
- return await this.mintServerNonce();
706
- }
707
- catch (error) {
708
- debug.warn('Could not mint server nonce, falling back to local nonce:', error instanceof Error ? error.message : String(error));
709
- return this.generateNonce();
710
- }
711
- }
712
- /**
713
- * Get the client ID for this origin
714
- *
715
- * @private
716
- */
717
- getClientId() {
718
- if (typeof window === 'undefined') {
719
- return 'unknown';
720
- }
721
- return window.location.origin;
722
- }
723
- /** @internal */
724
- getStoredLoginHint() {
725
- if (typeof window === 'undefined')
726
- return undefined;
727
- try {
728
- return localStorage.getItem(FEDCM_LOGIN_HINT_KEY) || undefined;
729
- }
730
- catch {
731
- return undefined;
732
- }
733
- }
734
- /** @internal */
735
- storeLoginHint(userId) {
736
- if (typeof window === 'undefined')
737
- return;
738
- try {
739
- localStorage.setItem(FEDCM_LOGIN_HINT_KEY, userId);
740
- }
741
- catch {
742
- // Storage full or blocked
743
- }
744
- }
745
- /** @internal */
746
- clearLoginHint() {
747
- if (typeof window === 'undefined')
748
- return;
749
- try {
750
- localStorage.removeItem(FEDCM_LOGIN_HINT_KEY);
751
- }
752
- catch {
753
- // Storage blocked
754
- }
755
- }
756
- /**
757
- * List the authenticated user's authorized RP apps.
758
- *
759
- * Returns the intersection of the user's FedCM grants and the currently-
760
- * approved RP catalog — what powers the "Connected apps" management UI in
761
- * @oxyhq/services. Requires a real user session; service tokens are
762
- * rejected by the underlying endpoint.
763
- */
764
- async listAuthorizedApps() {
765
- try {
766
- const response = await this.makeRequest('GET', '/fedcm/me/authorized-apps', undefined, {
767
- cache: true,
768
- cacheTTL: 30 * 1000, // 30 second cache — short, this drives a manageable UI
769
- });
770
- return response.apps ?? [];
771
- }
772
- catch (error) {
773
- throw this.handleError(error);
774
- }
775
- }
776
- /**
777
- * Revoke the authenticated user's authorization for a specific RP origin.
778
- *
779
- * The next FedCM sign-in from that origin will require explicit re-consent.
780
- * The corresponding cache entry is invalidated so a subsequent
781
- * `listAuthorizedApps()` call sees fresh data.
782
- */
783
- async revokeAuthorizedApp(origin) {
784
- try {
785
- await this.makeRequest('DELETE', `/fedcm/me/authorized-apps/${encodeURIComponent(origin)}`, undefined, { cache: false });
786
- this.clearCacheEntry('GET:/fedcm/me/authorized-apps');
787
- }
788
- catch (error) {
789
- throw this.handleError(error);
790
- }
791
- }
792
- },
793
- _a.DEFAULT_CONFIG_URL = 'https://auth.oxy.so/fedcm.json',
794
- _a.FEDCM_TIMEOUT = 15000 // 15 seconds for interactive
795
- ,
796
- // Silent mediation runs on page load as ONE step of the ordered cold-boot
797
- // sequence (mint nonce → navigator.credentials.get → /fedcm/exchange). The
798
- // real round-trip was measured at >3s for live users, so the budget must stay
799
- // comfortably above 3s. It must ALSO be tight: on a logged-out browser this
800
- // step never resolves a credential, and every millisecond it spends timing
801
- // out is pure latency in front of the steps that actually hold the answer
802
- // (stored-session bearer, the per-apex silent iframe, the /sso bounce). 4s is
803
- // the floor that preserves the >3s success margin while bounding the dead
804
- // wait — down from the previous 10s, which alone could account for most of a
805
- // 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
806
- _a.FEDCM_SILENT_TIMEOUT = 4000 // 4 seconds for silent mediation
807
- ,
808
- // Grace margin between the cooperative abort deadline (`FEDCM_SILENT_TIMEOUT`
809
- // / `FEDCM_TIMEOUT`) and the HARD settle of `requestIdentityCredential`. The
810
- // abort fires first; a well-behaved browser surfaces its own `AbortError`
811
- // within this window (keeping the existing error path intact). If — as seen
812
- // in production — `navigator.credentials.get()` ignores the abort and the
813
- // awaited promise never settles, the hard settle resolves the request to
814
- // `null` this many ms later, guaranteeing the cold-boot step always settles.
815
- // 500ms is ample for a browser to deliver an abort rejection while keeping the
816
- // worst-case dead wait tight (silent: 4.5s, interactive: 15.5s).
817
- _a.FEDCM_ABORT_SETTLE_GRACE_MS = 500,
818
- _a;
819
- }
820
- // Export the mixin function as both named and default
821
- export { OxyServicesFedCMMixin as FedCMMixin };