@oxyhq/core 1.2.3 → 1.2.5

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.
@@ -111,6 +111,10 @@ class AuthManager {
111
111
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
112
112
  };
113
113
  this.storage = this.config.storage;
114
+ // Persist tokens to storage when HttpService refreshes them automatically
115
+ this.oxyServices.httpService.onTokenRefreshed = (accessToken) => {
116
+ this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
117
+ };
114
118
  }
115
119
  /**
116
120
  * Get default storage based on environment.
@@ -86,6 +86,7 @@ class TokenStore {
86
86
  class HttpService {
87
87
  constructor(config) {
88
88
  this.tokenRefreshPromise = null;
89
+ this._onTokenRefreshed = null;
89
90
  // Performance monitoring
90
91
  this.requestMetrics = {
91
92
  totalRequests: 0,
@@ -228,7 +229,25 @@ class HttpService {
228
229
  clearTimeout(timeoutId);
229
230
  // Handle response
230
231
  if (!response.ok) {
231
- if (response.status === 401) {
232
+ // On 401, attempt token refresh and retry once before giving up
233
+ if (response.status === 401 && !config._isAuthRetry) {
234
+ const currentToken = this.tokenStore.getAccessToken();
235
+ if (currentToken) {
236
+ try {
237
+ const decoded = (0, jwt_decode_1.jwtDecode)(currentToken);
238
+ if (decoded.sessionId) {
239
+ const refreshResult = await this._refreshTokenFromSession(decoded.sessionId);
240
+ if (refreshResult) {
241
+ // Retry the request with the new token
242
+ return this.request({ ...config, _isAuthRetry: true, retry: false });
243
+ }
244
+ }
245
+ }
246
+ catch {
247
+ // Token decode failed, fall through to clear
248
+ }
249
+ }
250
+ // Refresh failed or no token — clear tokens
232
251
  this.tokenStore.clearTokens();
233
252
  }
234
253
  // Try to parse error response (handle empty/malformed JSON)
@@ -481,6 +500,7 @@ class HttpService {
481
500
  if (response.ok) {
482
501
  const { accessToken: newToken } = await response.json();
483
502
  this.tokenStore.setTokens(newToken);
503
+ this._onTokenRefreshed?.(newToken);
484
504
  this.logger.debug('Token refreshed');
485
505
  return `Bearer ${newToken}`;
486
506
  }
@@ -587,6 +607,9 @@ class HttpService {
587
607
  setTokens(accessToken, refreshToken = '') {
588
608
  this.tokenStore.setTokens(accessToken, refreshToken);
589
609
  }
610
+ set onTokenRefreshed(callback) {
611
+ this._onTokenRefreshed = callback;
612
+ }
590
613
  clearTokens() {
591
614
  this.tokenStore.clearTokens();
592
615
  this.tokenStore.clearCsrfToken();
@@ -105,12 +105,20 @@ function OxyServicesFedCMMixin(Base) {
105
105
  }
106
106
  catch (error) {
107
107
  debug.log('Interactive sign-in failed:', error);
108
+ const errorMessage = error instanceof Error ? error.message : String(error);
108
109
  if (error.name === 'AbortError') {
109
110
  throw new OxyServices_errors_1.OxyAuthenticationError('Sign-in was cancelled by user');
110
111
  }
111
112
  if (error.name === 'NetworkError') {
112
113
  throw new OxyServices_errors_1.OxyAuthenticationError('Network error during sign-in. Please check your connection.');
113
114
  }
115
+ if (errorMessage.includes('multiple accounts')) {
116
+ throw new OxyServices_errors_1.OxyAuthenticationError('Please sign out and sign in again to use FedCM with a single account');
117
+ }
118
+ if (errorMessage.includes('retrieving a token') || errorMessage.includes('Error retrieving')) {
119
+ debug.error('FedCM token retrieval error - this may be a browser or IdP configuration issue');
120
+ throw new OxyServices_errors_1.OxyAuthenticationError('Authentication failed. Please try again or use an alternative sign-in method.');
121
+ }
114
122
  throw error;
115
123
  }
116
124
  }
@@ -170,7 +178,17 @@ function OxyServicesFedCMMixin(Base) {
170
178
  catch (silentError) {
171
179
  const errorName = silentError instanceof Error ? silentError.name : 'Unknown';
172
180
  const errorMessage = silentError instanceof Error ? silentError.message : String(silentError);
173
- debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
181
+ // Handle specific FedCM errors with better logging
182
+ if (errorMessage.includes('multiple accounts')) {
183
+ debug.log('Silent SSO: User has used multiple accounts - silent mediation not available');
184
+ debug.log('Silent SSO: User needs to explicitly sign in to choose account');
185
+ }
186
+ else if (errorMessage.includes('conditions')) {
187
+ debug.log('Silent SSO: Conditions not met (user may not be logged in at IdP or not in approved_clients)');
188
+ }
189
+ else {
190
+ debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
191
+ }
174
192
  return null;
175
193
  }
176
194
  if (!credential || !credential.token) {
@@ -107,6 +107,10 @@ export class AuthManager {
107
107
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
108
108
  };
109
109
  this.storage = this.config.storage;
110
+ // Persist tokens to storage when HttpService refreshes them automatically
111
+ this.oxyServices.httpService.onTokenRefreshed = (accessToken) => {
112
+ this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
113
+ };
110
114
  }
111
115
  /**
112
116
  * Get default storage based on environment.
@@ -83,6 +83,7 @@ class TokenStore {
83
83
  export class HttpService {
84
84
  constructor(config) {
85
85
  this.tokenRefreshPromise = null;
86
+ this._onTokenRefreshed = null;
86
87
  // Performance monitoring
87
88
  this.requestMetrics = {
88
89
  totalRequests: 0,
@@ -225,7 +226,25 @@ export class HttpService {
225
226
  clearTimeout(timeoutId);
226
227
  // Handle response
227
228
  if (!response.ok) {
228
- if (response.status === 401) {
229
+ // On 401, attempt token refresh and retry once before giving up
230
+ if (response.status === 401 && !config._isAuthRetry) {
231
+ const currentToken = this.tokenStore.getAccessToken();
232
+ if (currentToken) {
233
+ try {
234
+ const decoded = jwtDecode(currentToken);
235
+ if (decoded.sessionId) {
236
+ const refreshResult = await this._refreshTokenFromSession(decoded.sessionId);
237
+ if (refreshResult) {
238
+ // Retry the request with the new token
239
+ return this.request({ ...config, _isAuthRetry: true, retry: false });
240
+ }
241
+ }
242
+ }
243
+ catch {
244
+ // Token decode failed, fall through to clear
245
+ }
246
+ }
247
+ // Refresh failed or no token — clear tokens
229
248
  this.tokenStore.clearTokens();
230
249
  }
231
250
  // Try to parse error response (handle empty/malformed JSON)
@@ -478,6 +497,7 @@ export class HttpService {
478
497
  if (response.ok) {
479
498
  const { accessToken: newToken } = await response.json();
480
499
  this.tokenStore.setTokens(newToken);
500
+ this._onTokenRefreshed?.(newToken);
481
501
  this.logger.debug('Token refreshed');
482
502
  return `Bearer ${newToken}`;
483
503
  }
@@ -584,6 +604,9 @@ export class HttpService {
584
604
  setTokens(accessToken, refreshToken = '') {
585
605
  this.tokenStore.setTokens(accessToken, refreshToken);
586
606
  }
607
+ set onTokenRefreshed(callback) {
608
+ this._onTokenRefreshed = callback;
609
+ }
587
610
  clearTokens() {
588
611
  this.tokenStore.clearTokens();
589
612
  this.tokenStore.clearCsrfToken();
@@ -101,12 +101,20 @@ export function OxyServicesFedCMMixin(Base) {
101
101
  }
102
102
  catch (error) {
103
103
  debug.log('Interactive sign-in failed:', error);
104
+ const errorMessage = error instanceof Error ? error.message : String(error);
104
105
  if (error.name === 'AbortError') {
105
106
  throw new OxyAuthenticationError('Sign-in was cancelled by user');
106
107
  }
107
108
  if (error.name === 'NetworkError') {
108
109
  throw new OxyAuthenticationError('Network error during sign-in. Please check your connection.');
109
110
  }
111
+ if (errorMessage.includes('multiple accounts')) {
112
+ throw new OxyAuthenticationError('Please sign out and sign in again to use FedCM with a single account');
113
+ }
114
+ if (errorMessage.includes('retrieving a token') || errorMessage.includes('Error retrieving')) {
115
+ debug.error('FedCM token retrieval error - this may be a browser or IdP configuration issue');
116
+ throw new OxyAuthenticationError('Authentication failed. Please try again or use an alternative sign-in method.');
117
+ }
110
118
  throw error;
111
119
  }
112
120
  }
@@ -166,7 +174,17 @@ export function OxyServicesFedCMMixin(Base) {
166
174
  catch (silentError) {
167
175
  const errorName = silentError instanceof Error ? silentError.name : 'Unknown';
168
176
  const errorMessage = silentError instanceof Error ? silentError.message : String(silentError);
169
- debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
177
+ // Handle specific FedCM errors with better logging
178
+ if (errorMessage.includes('multiple accounts')) {
179
+ debug.log('Silent SSO: User has used multiple accounts - silent mediation not available');
180
+ debug.log('Silent SSO: User needs to explicitly sign in to choose account');
181
+ }
182
+ else if (errorMessage.includes('conditions')) {
183
+ debug.log('Silent SSO: Conditions not met (user may not be logged in at IdP or not in approved_clients)');
184
+ }
185
+ else {
186
+ debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
187
+ }
170
188
  return null;
171
189
  }
172
190
  if (!credential || !credential.token) {
@@ -28,6 +28,8 @@ interface RequestConfig extends RequestOptions {
28
28
  url: string;
29
29
  data?: unknown;
30
30
  params?: Record<string, unknown>;
31
+ /** @internal Used to prevent infinite auth retry loops */
32
+ _isAuthRetry?: boolean;
31
33
  }
32
34
  /**
33
35
  * Unified HTTP Service
@@ -44,6 +46,7 @@ export declare class HttpService {
44
46
  private logger;
45
47
  private config;
46
48
  private tokenRefreshPromise;
49
+ private _onTokenRefreshed;
47
50
  private requestMetrics;
48
51
  constructor(config: OxyConfig);
49
52
  /**
@@ -140,6 +143,7 @@ export declare class HttpService {
140
143
  data: T;
141
144
  }>;
142
145
  setTokens(accessToken: string, refreshToken?: string): void;
146
+ set onTokenRefreshed(callback: ((accessToken: string) => void) | null);
143
147
  clearTokens(): void;
144
148
  getAccessToken(): string | null;
145
149
  hasAccessToken(): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -153,6 +153,11 @@ export class AuthManager {
153
153
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
154
154
  };
155
155
  this.storage = this.config.storage;
156
+
157
+ // Persist tokens to storage when HttpService refreshes them automatically
158
+ this.oxyServices.httpService.onTokenRefreshed = (accessToken: string) => {
159
+ this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
160
+ };
156
161
  }
157
162
 
158
163
  /**
@@ -52,6 +52,8 @@ interface RequestConfig extends RequestOptions {
52
52
  url: string;
53
53
  data?: unknown;
54
54
  params?: Record<string, unknown>;
55
+ /** @internal Used to prevent infinite auth retry loops */
56
+ _isAuthRetry?: boolean;
55
57
  }
56
58
 
57
59
  /**
@@ -132,6 +134,7 @@ export class HttpService {
132
134
  private logger: SimpleLogger;
133
135
  private config: OxyConfig;
134
136
  private tokenRefreshPromise: Promise<string | null> | null = null;
137
+ private _onTokenRefreshed: ((accessToken: string) => void) | null = null;
135
138
 
136
139
  // Performance monitoring
137
140
  private requestMetrics = {
@@ -322,10 +325,27 @@ export class HttpService {
322
325
 
323
326
  // Handle response
324
327
  if (!response.ok) {
325
- if (response.status === 401) {
328
+ // On 401, attempt token refresh and retry once before giving up
329
+ if (response.status === 401 && !config._isAuthRetry) {
330
+ const currentToken = this.tokenStore.getAccessToken();
331
+ if (currentToken) {
332
+ try {
333
+ const decoded = jwtDecode<JwtPayload>(currentToken);
334
+ if (decoded.sessionId) {
335
+ const refreshResult = await this._refreshTokenFromSession(decoded.sessionId);
336
+ if (refreshResult) {
337
+ // Retry the request with the new token
338
+ return this.request<T>({ ...config, _isAuthRetry: true, retry: false });
339
+ }
340
+ }
341
+ } catch {
342
+ // Token decode failed, fall through to clear
343
+ }
344
+ }
345
+ // Refresh failed or no token — clear tokens
326
346
  this.tokenStore.clearTokens();
327
347
  }
328
-
348
+
329
349
  // Try to parse error response (handle empty/malformed JSON)
330
350
  let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
331
351
  const contentType = response.headers.get('content-type');
@@ -343,10 +363,10 @@ export class HttpService {
343
363
  this.logger.warn('Failed to parse error response JSON:', parseError);
344
364
  }
345
365
  }
346
-
347
- const error = new Error(errorMessage) as Error & {
348
- status?: number;
349
- response?: { status: number; statusText: string }
366
+
367
+ const error = new Error(errorMessage) as Error & {
368
+ status?: number;
369
+ response?: { status: number; statusText: string }
350
370
  };
351
371
  error.status = response.status;
352
372
  error.response = { status: response.status, statusText: response.statusText };
@@ -596,6 +616,7 @@ export class HttpService {
596
616
  if (response.ok) {
597
617
  const { accessToken: newToken } = await response.json();
598
618
  this.tokenStore.setTokens(newToken);
619
+ this._onTokenRefreshed?.(newToken);
599
620
  this.logger.debug('Token refreshed');
600
621
  return `Bearer ${newToken}`;
601
622
  }
@@ -712,6 +733,10 @@ export class HttpService {
712
733
  this.tokenStore.setTokens(accessToken, refreshToken);
713
734
  }
714
735
 
736
+ set onTokenRefreshed(callback: ((accessToken: string) => void) | null) {
737
+ this._onTokenRefreshed = callback;
738
+ }
739
+
715
740
  clearTokens(): void {
716
741
  this.tokenStore.clearTokens();
717
742
  this.tokenStore.clearCsrfToken();
@@ -131,12 +131,21 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
131
131
  return session;
132
132
  } catch (error) {
133
133
  debug.log('Interactive sign-in failed:', error);
134
+ const errorMessage = error instanceof Error ? error.message : String(error);
135
+
134
136
  if ((error as any).name === 'AbortError') {
135
137
  throw new OxyAuthenticationError('Sign-in was cancelled by user');
136
138
  }
137
139
  if ((error as any).name === 'NetworkError') {
138
140
  throw new OxyAuthenticationError('Network error during sign-in. Please check your connection.');
139
141
  }
142
+ if (errorMessage.includes('multiple accounts')) {
143
+ throw new OxyAuthenticationError('Please sign out and sign in again to use FedCM with a single account');
144
+ }
145
+ if (errorMessage.includes('retrieving a token') || errorMessage.includes('Error retrieving')) {
146
+ debug.error('FedCM token retrieval error - this may be a browser or IdP configuration issue');
147
+ throw new OxyAuthenticationError('Authentication failed. Please try again or use an alternative sign-in method.');
148
+ }
140
149
  throw error;
141
150
  }
142
151
  }
@@ -201,7 +210,17 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
201
210
  } catch (silentError) {
202
211
  const errorName = silentError instanceof Error ? silentError.name : 'Unknown';
203
212
  const errorMessage = silentError instanceof Error ? silentError.message : String(silentError);
204
- debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
213
+
214
+ // Handle specific FedCM errors with better logging
215
+ if (errorMessage.includes('multiple accounts')) {
216
+ debug.log('Silent SSO: User has used multiple accounts - silent mediation not available');
217
+ debug.log('Silent SSO: User needs to explicitly sign in to choose account');
218
+ } else if (errorMessage.includes('conditions')) {
219
+ debug.log('Silent SSO: Conditions not met (user may not be logged in at IdP or not in approved_clients)');
220
+ } else {
221
+ debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
222
+ }
223
+
205
224
  return null;
206
225
  }
207
226