@oxyhq/core 1.11.24 → 2.1.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 (150) hide show
  1. package/README.md +5 -6
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/AuthManager.js +678 -4
  4. package/dist/cjs/AuthManagerTypes.js +13 -0
  5. package/dist/cjs/CrossDomainAuth.js +45 -3
  6. package/dist/cjs/OxyServices.base.js +16 -0
  7. package/dist/cjs/i18n/locales/ar-SA.json +83 -0
  8. package/dist/cjs/i18n/locales/ca-ES.json +83 -0
  9. package/dist/cjs/i18n/locales/de-DE.json +83 -0
  10. package/dist/cjs/i18n/locales/en-US.json +83 -0
  11. package/dist/cjs/i18n/locales/es-ES.json +99 -4
  12. package/dist/cjs/i18n/locales/fr-FR.json +83 -0
  13. package/dist/cjs/i18n/locales/it-IT.json +83 -0
  14. package/dist/cjs/i18n/locales/ja-JP.json +83 -0
  15. package/dist/cjs/i18n/locales/ko-KR.json +83 -0
  16. package/dist/cjs/i18n/locales/locales/ar-SA.json +83 -1
  17. package/dist/cjs/i18n/locales/locales/ca-ES.json +83 -1
  18. package/dist/cjs/i18n/locales/locales/de-DE.json +83 -1
  19. package/dist/cjs/i18n/locales/locales/en-US.json +83 -0
  20. package/dist/cjs/i18n/locales/locales/es-ES.json +99 -4
  21. package/dist/cjs/i18n/locales/locales/fr-FR.json +83 -1
  22. package/dist/cjs/i18n/locales/locales/it-IT.json +83 -1
  23. package/dist/cjs/i18n/locales/locales/ja-JP.json +200 -117
  24. package/dist/cjs/i18n/locales/locales/ko-KR.json +83 -1
  25. package/dist/cjs/i18n/locales/locales/pt-PT.json +83 -1
  26. package/dist/cjs/i18n/locales/locales/zh-CN.json +83 -1
  27. package/dist/cjs/i18n/locales/pt-PT.json +83 -0
  28. package/dist/cjs/i18n/locales/zh-CN.json +83 -0
  29. package/dist/cjs/index.js +121 -57
  30. package/dist/cjs/mixins/OxyServices.auth.js +235 -0
  31. package/dist/cjs/mixins/OxyServices.fedcm.js +36 -0
  32. package/dist/cjs/mixins/OxyServices.popup.js +61 -1
  33. package/dist/cjs/mixins/OxyServices.user.js +18 -0
  34. package/dist/cjs/utils/accountUtils.js +64 -1
  35. package/dist/cjs/utils/coldBoot.js +71 -0
  36. package/dist/cjs/utils/fapiAutoDetect.js +88 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/AuthManager.js +678 -4
  39. package/dist/esm/AuthManagerTypes.js +12 -0
  40. package/dist/esm/CrossDomainAuth.js +45 -3
  41. package/dist/esm/OxyServices.base.js +16 -0
  42. package/dist/esm/i18n/locales/ar-SA.json +83 -0
  43. package/dist/esm/i18n/locales/ca-ES.json +83 -0
  44. package/dist/esm/i18n/locales/de-DE.json +83 -0
  45. package/dist/esm/i18n/locales/en-US.json +83 -0
  46. package/dist/esm/i18n/locales/es-ES.json +99 -4
  47. package/dist/esm/i18n/locales/fr-FR.json +83 -0
  48. package/dist/esm/i18n/locales/it-IT.json +83 -0
  49. package/dist/esm/i18n/locales/ja-JP.json +83 -0
  50. package/dist/esm/i18n/locales/ko-KR.json +83 -0
  51. package/dist/esm/i18n/locales/locales/ar-SA.json +83 -1
  52. package/dist/esm/i18n/locales/locales/ca-ES.json +83 -1
  53. package/dist/esm/i18n/locales/locales/de-DE.json +83 -1
  54. package/dist/esm/i18n/locales/locales/en-US.json +83 -0
  55. package/dist/esm/i18n/locales/locales/es-ES.json +99 -4
  56. package/dist/esm/i18n/locales/locales/fr-FR.json +83 -1
  57. package/dist/esm/i18n/locales/locales/it-IT.json +83 -1
  58. package/dist/esm/i18n/locales/locales/ja-JP.json +200 -117
  59. package/dist/esm/i18n/locales/locales/ko-KR.json +83 -1
  60. package/dist/esm/i18n/locales/locales/pt-PT.json +83 -1
  61. package/dist/esm/i18n/locales/locales/zh-CN.json +83 -1
  62. package/dist/esm/i18n/locales/pt-PT.json +83 -0
  63. package/dist/esm/i18n/locales/zh-CN.json +83 -0
  64. package/dist/esm/index.js +74 -26
  65. package/dist/esm/mixins/OxyServices.auth.js +235 -0
  66. package/dist/esm/mixins/OxyServices.fedcm.js +36 -0
  67. package/dist/esm/mixins/OxyServices.popup.js +61 -1
  68. package/dist/esm/mixins/OxyServices.user.js +18 -0
  69. package/dist/esm/utils/accountUtils.js +61 -0
  70. package/dist/esm/utils/coldBoot.js +68 -0
  71. package/dist/esm/utils/fapiAutoDetect.js +85 -0
  72. package/dist/types/.tsbuildinfo +1 -1
  73. package/dist/types/AuthManager.d.ts +243 -3
  74. package/dist/types/AuthManagerTypes.d.ts +68 -0
  75. package/dist/types/CrossDomainAuth.d.ts +23 -0
  76. package/dist/types/OxyServices.base.d.ts +14 -0
  77. package/dist/types/OxyServices.d.ts +7 -0
  78. package/dist/types/index.d.ts +31 -17
  79. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  80. package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.assets.d.ts +4 -1
  82. package/dist/types/mixins/OxyServices.auth.d.ts +73 -1
  83. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.features.d.ts +2 -5
  87. package/dist/types/mixins/OxyServices.fedcm.d.ts +34 -0
  88. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  90. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  91. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  92. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  93. package/dist/types/mixins/OxyServices.popup.d.ts +40 -0
  94. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  95. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  96. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  97. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  98. package/dist/types/mixins/OxyServices.user.d.ts +16 -1
  99. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  100. package/dist/types/models/interfaces.d.ts +98 -0
  101. package/dist/types/models/session.d.ts +8 -0
  102. package/dist/types/utils/accountUtils.d.ts +33 -0
  103. package/dist/types/utils/coldBoot.d.ts +102 -0
  104. package/dist/types/utils/fapiAutoDetect.d.ts +37 -0
  105. package/package.json +9 -18
  106. package/src/AuthManager.ts +776 -7
  107. package/src/AuthManagerTypes.ts +72 -0
  108. package/src/CrossDomainAuth.ts +54 -3
  109. package/src/OxyServices.base.ts +17 -0
  110. package/src/OxyServices.ts +7 -0
  111. package/src/__tests__/authManager.cookiePath.test.ts +339 -0
  112. package/src/__tests__/authManager.security.test.ts +342 -0
  113. package/src/__tests__/crossDomainAuth.test.ts +191 -0
  114. package/src/i18n/locales/ar-SA.json +83 -1
  115. package/src/i18n/locales/ca-ES.json +83 -1
  116. package/src/i18n/locales/de-DE.json +83 -1
  117. package/src/i18n/locales/en-US.json +83 -0
  118. package/src/i18n/locales/es-ES.json +99 -4
  119. package/src/i18n/locales/fr-FR.json +83 -1
  120. package/src/i18n/locales/it-IT.json +83 -1
  121. package/src/i18n/locales/ja-JP.json +200 -117
  122. package/src/i18n/locales/ko-KR.json +83 -1
  123. package/src/i18n/locales/pt-PT.json +83 -1
  124. package/src/i18n/locales/zh-CN.json +83 -1
  125. package/src/index.ts +309 -112
  126. package/src/mixins/OxyServices.auth.ts +268 -1
  127. package/src/mixins/OxyServices.fedcm.ts +63 -0
  128. package/src/mixins/OxyServices.popup.ts +79 -1
  129. package/src/mixins/OxyServices.user.ts +33 -1
  130. package/src/mixins/__tests__/popup.test.ts +307 -0
  131. package/src/mixins/__tests__/sessionBaseUrl.test.ts +61 -0
  132. package/src/models/interfaces.ts +116 -0
  133. package/src/models/session.ts +8 -0
  134. package/src/utils/__tests__/coldBoot.test.ts +226 -0
  135. package/src/utils/__tests__/fapiAutoDetect.test.ts +93 -0
  136. package/src/utils/accountUtils.ts +84 -0
  137. package/src/utils/coldBoot.ts +136 -0
  138. package/src/utils/fapiAutoDetect.ts +82 -0
  139. package/dist/cjs/crypto/index.js +0 -22
  140. package/dist/cjs/shared/index.js +0 -70
  141. package/dist/cjs/utils/index.js +0 -26
  142. package/dist/esm/crypto/index.js +0 -13
  143. package/dist/esm/shared/index.js +0 -31
  144. package/dist/esm/utils/index.js +0 -7
  145. package/dist/types/crypto/index.d.ts +0 -11
  146. package/dist/types/shared/index.d.ts +0 -28
  147. package/dist/types/utils/index.d.ts +0 -6
  148. package/src/crypto/index.ts +0 -30
  149. package/src/shared/index.ts +0 -82
  150. package/src/utils/index.ts +0 -21
@@ -0,0 +1,72 @@
1
+ /**
2
+ * AuthManager — public types for the multi-account cookie path.
3
+ *
4
+ * Lives in its own module (rather than the 670-line `models/interfaces.ts`)
5
+ * so consumers can `import type` exactly the multi-account surface without
6
+ * pulling in the full interfaces graph, and so `AuthManager.ts` stays
7
+ * decoupled from the wire shapes — these types re-state the wire as the
8
+ * AuthManager's in-memory representation.
9
+ *
10
+ * @module core/AuthManagerTypes
11
+ */
12
+
13
+ import type { RefreshAllAccountUser } from './models/interfaces';
14
+
15
+ /**
16
+ * One device-local account known to `AuthManager` in the cookie path.
17
+ *
18
+ * Built from a `POST /auth/refresh-all` entry, OR from a single
19
+ * `POST /auth/refresh?authuser=N` rotation after a switch, OR from a
20
+ * `handleAuthSuccess` call after a fresh login. The `accessToken` is held in
21
+ * memory only — the refresh token never enters JS (it lives in the httpOnly
22
+ * `oxy_rt_${authuser}` cookie).
23
+ */
24
+ export interface AuthManagerAccount {
25
+ /** Device-local cookie slot index (0..N-1). */
26
+ authuser: number;
27
+ /** Server-side session id this slot is bound to. */
28
+ sessionId: string;
29
+ /**
30
+ * Projected user shape from the wire (username/avatar/color/email).
31
+ *
32
+ * `null` when a refresh-via-cookie planted a fresh access token for a slot
33
+ * that the AuthManager has no prior in-memory user metadata for — e.g. the
34
+ * legacy `/auth/refresh` 404 fallback path inside `refreshAllSessions`, or
35
+ * a `switchAuthuser` against a slot that wasn't present in the previous
36
+ * `restoreFromCookies` snapshot. Callers (or the AuthManager itself) are
37
+ * expected to hydrate the user shape via `getCurrentUser()` after the token
38
+ * is planted; the chooser UI must render the public-key fallback handle
39
+ * until the hydration completes.
40
+ */
41
+ user: RefreshAllAccountUser | null;
42
+ /** Currently-valid access token for this slot (in-memory only). */
43
+ accessToken: string;
44
+ /** ISO-8601 expiry of the access token. */
45
+ expiresAt: string;
46
+ }
47
+
48
+ /**
49
+ * Outcome of `AuthManager.restoreFromCookies()`.
50
+ *
51
+ * `accounts` is sorted by `authuser` ascending (matching the server's
52
+ * canonical ordering). `activeAuthuser` is whichever slot the AuthManager
53
+ * picked as active — usually the persisted `oxy_active_authuser` if it
54
+ * matched a returned slot, otherwise the lowest returned `authuser`, or
55
+ * `null` if no accounts were restored.
56
+ */
57
+ export interface RestoreFromCookiesResult {
58
+ accounts: AuthManagerAccount[];
59
+ activeAuthuser: number | null;
60
+ }
61
+
62
+ /**
63
+ * Outcome of `AuthManager.switchAuthuser()`.
64
+ *
65
+ * Mirrors the wire `RefreshCookieResponse` but with `authuser` narrowed to
66
+ * `number` (the SDK boundary normalises the legacy `null` slot to `0`).
67
+ */
68
+ export interface SwitchAuthuserResult {
69
+ accessToken: string;
70
+ expiresAt: string;
71
+ authuser: number;
72
+ }
@@ -57,6 +57,14 @@ export interface CrossDomainAuthOptions {
57
57
  * Callback when auth method is selected
58
58
  */
59
59
  onMethodSelected?: (method: 'fedcm' | 'popup' | 'redirect') => void;
60
+
61
+ /**
62
+ * A popup window the caller already opened SYNCHRONOUSLY in the user-gesture
63
+ * handler. Forwarded to `OxyServices.signInWithPopup` so the popup is not
64
+ * blocked by Chrome after any prior `await` (FedCM / silent SSO) has
65
+ * consumed the transient user activation. See `OxyServices.openBlankPopup`.
66
+ */
67
+ popup?: Window | null;
60
68
  }
61
69
 
62
70
  export class CrossDomainAuth {
@@ -76,9 +84,20 @@ export class CrossDomainAuth {
76
84
  async signIn(options: CrossDomainAuthOptions = {}): Promise<SessionLoginResponse | null> {
77
85
  const method = options.method || 'auto';
78
86
 
79
- // If specific method requested, use it directly
87
+ // If specific method requested, use it directly. The caller MAY have
88
+ // pre-opened a popup on the raw click (the standard pattern in
89
+ // WebOxyProvider / services useAuth). For the FedCM and redirect paths
90
+ // that popup is unused — close it so it doesn't linger as an orphaned
91
+ // blank window. Close in both success and failure paths.
80
92
  if (method === 'fedcm') {
81
- return this.signInWithFedCM(options);
93
+ try {
94
+ const session = await this.signInWithFedCM(options);
95
+ this.closeOrphanPopup(options.popup);
96
+ return session;
97
+ } catch (error) {
98
+ this.closeOrphanPopup(options.popup);
99
+ throw error;
100
+ }
82
101
  }
83
102
 
84
103
  if (method === 'popup') {
@@ -86,6 +105,7 @@ export class CrossDomainAuth {
86
105
  }
87
106
 
88
107
  if (method === 'redirect') {
108
+ this.closeOrphanPopup(options.popup);
89
109
  this.signInWithRedirect(options);
90
110
  return null; // Redirect doesn't return immediately
91
111
  }
@@ -94,6 +114,19 @@ export class CrossDomainAuth {
94
114
  return this.autoSignIn(options);
95
115
  }
96
116
 
117
+ /**
118
+ * Close a caller-supplied popup window that is no longer needed (e.g. the
119
+ * resolved auth method didn't end up using it). Safe against null / already
120
+ * closed handles.
121
+ *
122
+ * @private
123
+ */
124
+ private closeOrphanPopup(popup: Window | null | undefined): void {
125
+ if (popup && !popup.closed) {
126
+ popup.close();
127
+ }
128
+ }
129
+
97
130
  /**
98
131
  * Automatic sign-in with progressive enhancement
99
132
  *
@@ -104,7 +137,11 @@ export class CrossDomainAuth {
104
137
  if (this.isFedCMSupported()) {
105
138
  try {
106
139
  options.onMethodSelected?.('fedcm');
107
- return await this.signInWithFedCM(options);
140
+ const session = await this.signInWithFedCM(options);
141
+ // FedCM succeeded — close the pre-opened popup so it doesn't linger
142
+ // as an orphaned blank window.
143
+ this.closeOrphanPopup(options.popup);
144
+ return session;
108
145
  } catch (error) {
109
146
  console.warn('[CrossDomainAuth] FedCM failed, trying popup...', error);
110
147
  }
@@ -116,6 +153,8 @@ export class CrossDomainAuth {
116
153
  return await this.signInWithPopup(options);
117
154
  } catch (error) {
118
155
  console.warn('[CrossDomainAuth] Popup failed, falling back to redirect...', error);
156
+ // Popup path failed — close the pre-opened popup before redirecting.
157
+ this.closeOrphanPopup(options.popup);
119
158
  }
120
159
 
121
160
  // 3. Fallback to redirect (always works)
@@ -145,6 +184,7 @@ export class CrossDomainAuth {
145
184
  mode: options.isSignup ? 'signup' : 'login',
146
185
  width: options.popupDimensions?.width,
147
186
  height: options.popupDimensions?.height,
187
+ popup: options.popup ?? undefined,
148
188
  });
149
189
  }
150
190
 
@@ -208,6 +248,17 @@ export class CrossDomainAuth {
208
248
  return this.oxyServices.restoreSession?.() || false;
209
249
  }
210
250
 
251
+ /**
252
+ * Open a blank popup SYNCHRONOUSLY (call from a raw user-gesture handler
253
+ * BEFORE any `await`). Returns `null` if the popup was blocked. Pass the
254
+ * handle into `signIn({ popup })` / `signInWithPopup({ popup })` so the
255
+ * popup is not blocked by Chrome after any prior `await` consumed the
256
+ * transient user activation. Delegates to `OxyServices.openBlankPopup`.
257
+ */
258
+ openBlankPopup(width?: number, height?: number): Window | null {
259
+ return this.oxyServices.openBlankPopup(width, height);
260
+ }
261
+
211
262
  /**
212
263
  * Check if FedCM is supported in current browser
213
264
  */
@@ -77,6 +77,23 @@ export class OxyServicesBase {
77
77
  return this.httpService.getBaseURL();
78
78
  }
79
79
 
80
+ /**
81
+ * Get the base URL the SDK's first-party session/refresh calls should target.
82
+ *
83
+ * Returns the configured `sessionBaseUrl` when provided, otherwise falls back
84
+ * to the API `baseURL` (`getBaseURL()`). Per the 2026 session architecture
85
+ * (docs/SESSION-ARCHITECTURE.md), non-`oxy.so` apps point this at their own
86
+ * same-site backend (e.g. `https://api.mention.earth`) whose session bridge
87
+ * forwards the user's refresh credential to `api.oxy.so`; `*.oxy.so` apps
88
+ * leave it unset so it resolves to `https://api.oxy.so` and nothing changes.
89
+ *
90
+ * This is additive: it only exposes configuration for `@oxyhq/services` to
91
+ * consume in a later phase. No refresh/auth logic in core reads it yet.
92
+ */
93
+ public getSessionBaseUrl(): string {
94
+ return this.config.sessionBaseUrl ?? this.getBaseURL();
95
+ }
96
+
80
97
  /**
81
98
  * Get the HTTP service instance
82
99
  * Useful for advanced use cases where direct access to the HTTP service is needed
@@ -139,6 +139,13 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
139
139
  // Popup authentication
140
140
  signInWithPopup(options?: PopupAuthOptions): Promise<SessionLoginResponse>;
141
141
  signUpWithPopup(options?: PopupAuthOptions): Promise<SessionLoginResponse>;
142
+ /**
143
+ * Open a blank popup SYNCHRONOUSLY (call from a raw user-gesture handler
144
+ * BEFORE any `await`). Returns `null` if the popup was blocked. Pass the
145
+ * handle into `signInWithPopup({ popup })` to navigate it to auth.oxy.so
146
+ * after the async portion of the sign-in flow runs.
147
+ */
148
+ openBlankPopup(width?: number, height?: number): Window | null;
142
149
 
143
150
  // Redirect authentication
144
151
  signInWithRedirect(options?: RedirectAuthOptions): void;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * AuthManager multi-account cookie-path regression tests.
3
+ *
4
+ * Locks in the four new methods that route through the httpOnly
5
+ * `oxy_rt_${authuser}` refresh cookies instead of the legacy bearer
6
+ * `/session/token/:id` endpoint:
7
+ *
8
+ * - `restoreFromCookies()` — cold-boot restore of every device-local slot
9
+ * via `POST /auth/refresh-all`. Picks active slot by persisted
10
+ * `oxy_active_authuser`, falling back to lowest authuser.
11
+ * - `switchAuthuser(n)` — mint a fresh access token for slot `n` via
12
+ * `POST /auth/refresh?authuser=N`, plant it, persist active.
13
+ * - `signOutAuthuser(n)` — `POST /auth/logout?authuser=N`, drop slot
14
+ * locally, promote lowest remaining as active (or clear).
15
+ * - `signOutAllViaCookies()` — `POST /auth/logout`, clear every slot,
16
+ * clear persisted active.
17
+ *
18
+ * Storage rule: the cookie path NEVER reads or writes
19
+ * `oxy_access_token` / `oxy_refresh_token` / `oxy_session`. Only the
20
+ * integer slot index lives in `oxy_active_authuser` (not a secret).
21
+ */
22
+
23
+ import { AuthManager } from '../AuthManager';
24
+ import type { StorageAdapter } from '../AuthManager';
25
+ import type { OxyServices } from '../OxyServices';
26
+ import type { RefreshAllResponse } from '../models/interfaces';
27
+
28
+ const ACTIVE_AUTHUSER_KEY = 'oxy_active_authuser';
29
+
30
+ function buildAccessToken(claims: Record<string, unknown>): string {
31
+ const b64url = (value: string): string =>
32
+ Buffer.from(value).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
33
+ const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
34
+ const payload = b64url(JSON.stringify(claims));
35
+ return `${header}.${payload}.signature`;
36
+ }
37
+
38
+ class InMemoryStorage implements StorageAdapter {
39
+ private store = new Map<string, string>();
40
+ getItem(key: string): string | null { return this.store.get(key) ?? null; }
41
+ setItem(key: string, value: string): void { this.store.set(key, value); }
42
+ removeItem(key: string): void { this.store.delete(key); }
43
+ has(key: string): boolean { return this.store.has(key); }
44
+ raw(): Map<string, string> { return this.store; }
45
+ }
46
+
47
+ interface MockServices {
48
+ refreshAllSessions: jest.Mock<Promise<RefreshAllResponse>, []>;
49
+ refreshTokenViaCookie: jest.Mock;
50
+ logoutSessionByAuthuser: jest.Mock<Promise<void>, [number]>;
51
+ logoutAllSessionsViaCookie: jest.Mock<Promise<void>, []>;
52
+ httpService: { setTokens: jest.Mock; onTokenRefreshed: ((t: string) => void) | undefined };
53
+ }
54
+
55
+ function makeMockServices(): MockServices {
56
+ return {
57
+ refreshAllSessions: jest.fn(async (): Promise<RefreshAllResponse> => ({ accounts: [] })),
58
+ refreshTokenViaCookie: jest.fn(),
59
+ logoutSessionByAuthuser: jest.fn(async () => undefined),
60
+ logoutAllSessionsViaCookie: jest.fn(async () => undefined),
61
+ httpService: { setTokens: jest.fn(), onTokenRefreshed: undefined },
62
+ };
63
+ }
64
+
65
+ function makeManager(services: MockServices, storage: InMemoryStorage): AuthManager {
66
+ const oxyServices = services as unknown as OxyServices;
67
+ return new AuthManager(oxyServices, {
68
+ storage,
69
+ autoRefresh: false,
70
+ crossTabSync: false,
71
+ cookieOnly: true,
72
+ });
73
+ }
74
+
75
+ const TOKEN_SLOT_0 = buildAccessToken({ sessionId: 'sess-slot-0', userId: 'user-0', exp: 9999999999 });
76
+ const TOKEN_SLOT_1 = buildAccessToken({ sessionId: 'sess-slot-1', userId: 'user-1', exp: 9999999999 });
77
+
78
+ const TWO_ACCOUNTS: RefreshAllResponse = {
79
+ accounts: [
80
+ {
81
+ authuser: 0,
82
+ accessToken: TOKEN_SLOT_0,
83
+ expiresAt: '2099-01-01T00:00:00.000Z',
84
+ sessionId: 'sess-slot-0',
85
+ user: { id: 'user-0', username: 'alice', avatar: null, color: '#1abc9c' },
86
+ },
87
+ {
88
+ authuser: 1,
89
+ accessToken: TOKEN_SLOT_1,
90
+ expiresAt: '2099-01-01T00:00:00.000Z',
91
+ sessionId: 'sess-slot-1',
92
+ user: { id: 'user-1', username: 'bob', avatar: null, color: '#3498db' },
93
+ },
94
+ ],
95
+ };
96
+
97
+ describe('AuthManager.restoreFromCookies', () => {
98
+ it('plants every account in the registry, picks lowest authuser when nothing is persisted, and persists the chosen slot', async () => {
99
+ const services = makeMockServices();
100
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
101
+ const storage = new InMemoryStorage();
102
+ const manager = makeManager(services, storage);
103
+
104
+ const result = await manager.restoreFromCookies();
105
+
106
+ expect(result.accounts).toHaveLength(2);
107
+ expect(result.activeAuthuser).toBe(0);
108
+ expect(manager.getActiveAuthuser()).toBe(0);
109
+
110
+ // Active slot's access token is planted on the HTTP client.
111
+ expect(services.httpService.setTokens).toHaveBeenCalledWith(TOKEN_SLOT_0);
112
+
113
+ // Persisted active authuser — the ONLY storage write of the cookie path.
114
+ expect(storage.has(ACTIVE_AUTHUSER_KEY)).toBe(true);
115
+ expect(storage.raw().get(ACTIVE_AUTHUSER_KEY)).toBe('0');
116
+
117
+ // Sibling slot is in the registry but its token is NOT on the HTTP
118
+ // client (that would clobber the active slot). Stays in-memory for a
119
+ // future switchAuthuser hot-swap.
120
+ const sibling = manager.getAccounts().find((a) => a.authuser === 1);
121
+ expect(sibling?.accessToken).toBe(TOKEN_SLOT_1);
122
+
123
+ // Legacy token storage MUST stay empty in the cookie path.
124
+ expect(storage.has('oxy_access_token')).toBe(false);
125
+ expect(storage.has('oxy_refresh_token')).toBe(false);
126
+ expect(storage.has('oxy_session')).toBe(false);
127
+ });
128
+
129
+ it('honours persisted oxy_active_authuser when it matches a returned account', async () => {
130
+ const services = makeMockServices();
131
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
132
+ const storage = new InMemoryStorage();
133
+ storage.setItem(ACTIVE_AUTHUSER_KEY, '1');
134
+ const manager = makeManager(services, storage);
135
+
136
+ const result = await manager.restoreFromCookies();
137
+
138
+ expect(result.activeAuthuser).toBe(1);
139
+ // Slot 1's token planted, not slot 0's.
140
+ expect(services.httpService.setTokens).toHaveBeenCalledWith(TOKEN_SLOT_1);
141
+ });
142
+
143
+ it('falls back to lowest authuser when persisted slot is no longer returned', async () => {
144
+ const services = makeMockServices();
145
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
146
+ const storage = new InMemoryStorage();
147
+ storage.setItem(ACTIVE_AUTHUSER_KEY, '7'); // stale: server doesn't return slot 7
148
+ const manager = makeManager(services, storage);
149
+
150
+ const result = await manager.restoreFromCookies();
151
+
152
+ expect(result.activeAuthuser).toBe(0);
153
+ // And the persisted value is corrected to the new active.
154
+ expect(storage.raw().get(ACTIVE_AUTHUSER_KEY)).toBe('0');
155
+ });
156
+
157
+ it('returns an empty result without throwing on snapshot failure', async () => {
158
+ const services = makeMockServices();
159
+ services.refreshAllSessions.mockRejectedValueOnce(new TypeError('Failed to fetch'));
160
+ const storage = new InMemoryStorage();
161
+ const manager = makeManager(services, storage);
162
+
163
+ const result = await manager.restoreFromCookies();
164
+
165
+ expect(result.accounts).toEqual([]);
166
+ expect(result.activeAuthuser).toBeNull();
167
+ expect(services.httpService.setTokens).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it('returns an empty result when no signed-in accounts on this device', async () => {
171
+ const services = makeMockServices();
172
+ // Default mock already returns `{ accounts: [] }`.
173
+ const storage = new InMemoryStorage();
174
+ const manager = makeManager(services, storage);
175
+
176
+ const result = await manager.restoreFromCookies();
177
+
178
+ expect(result.accounts).toEqual([]);
179
+ expect(result.activeAuthuser).toBeNull();
180
+ expect(manager.getActiveAccount()).toBeNull();
181
+ expect(services.httpService.setTokens).not.toHaveBeenCalled();
182
+ });
183
+ });
184
+
185
+ describe('AuthManager.switchAuthuser', () => {
186
+ it('rotates a slot via refreshTokenViaCookie, plants its token, and persists the new active', async () => {
187
+ const services = makeMockServices();
188
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
189
+ services.refreshTokenViaCookie.mockResolvedValueOnce({
190
+ accessToken: 'rotated-slot-1-token',
191
+ expiresAt: '2099-01-01T00:00:00.000Z',
192
+ authuser: 1,
193
+ });
194
+ const storage = new InMemoryStorage();
195
+ const manager = makeManager(services, storage);
196
+
197
+ await manager.restoreFromCookies();
198
+ expect(manager.getActiveAuthuser()).toBe(0);
199
+
200
+ const switched = await manager.switchAuthuser(1);
201
+
202
+ expect(services.refreshTokenViaCookie).toHaveBeenCalledWith({ authuser: 1 });
203
+ expect(switched.authuser).toBe(1);
204
+ expect(switched.accessToken).toBe('rotated-slot-1-token');
205
+
206
+ expect(manager.getActiveAuthuser()).toBe(1);
207
+ expect(services.httpService.setTokens).toHaveBeenLastCalledWith('rotated-slot-1-token');
208
+ expect(storage.raw().get(ACTIVE_AUTHUSER_KEY)).toBe('1');
209
+
210
+ // The registry entry for slot 1 is updated to the rotated token; slot 0
211
+ // is untouched (its access token is still valid).
212
+ const slot1 = manager.getAccounts().find((a) => a.authuser === 1);
213
+ expect(slot1?.accessToken).toBe('rotated-slot-1-token');
214
+ const slot0 = manager.getAccounts().find((a) => a.authuser === 0);
215
+ expect(slot0?.accessToken).toBe(TOKEN_SLOT_0);
216
+ });
217
+
218
+ it('throws and drops the slot when the cookie is missing/expired (refresh returns null)', async () => {
219
+ const services = makeMockServices();
220
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
221
+ services.refreshTokenViaCookie.mockResolvedValueOnce(null);
222
+ const storage = new InMemoryStorage();
223
+ const manager = makeManager(services, storage);
224
+
225
+ await manager.restoreFromCookies();
226
+
227
+ await expect(manager.switchAuthuser(1)).rejects.toThrow(/authuser=1/);
228
+
229
+ // Slot 1 was removed from the registry; slot 0 remains and is still active.
230
+ expect(manager.getAccounts().map((a) => a.authuser)).toEqual([0]);
231
+ expect(manager.getActiveAuthuser()).toBe(0);
232
+ });
233
+ });
234
+
235
+ describe('AuthManager.signOutAuthuser', () => {
236
+ it('revokes the slot server-side, drops it from the registry, and promotes lowest remaining as active', async () => {
237
+ const services = makeMockServices();
238
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
239
+ const storage = new InMemoryStorage();
240
+ const manager = makeManager(services, storage);
241
+
242
+ await manager.restoreFromCookies();
243
+ // Active = slot 0; sign it out.
244
+ await manager.signOutAuthuser(0);
245
+
246
+ expect(services.logoutSessionByAuthuser).toHaveBeenCalledWith(0);
247
+ expect(manager.getAccounts().map((a) => a.authuser)).toEqual([1]);
248
+ expect(manager.getActiveAuthuser()).toBe(1);
249
+ // Slot 1's cached access token gets planted as the new active.
250
+ expect(services.httpService.setTokens).toHaveBeenLastCalledWith(TOKEN_SLOT_1);
251
+ expect(storage.raw().get(ACTIVE_AUTHUSER_KEY)).toBe('1');
252
+ });
253
+
254
+ it('clears state entirely when the last slot is signed out', async () => {
255
+ const services = makeMockServices();
256
+ services.refreshAllSessions.mockResolvedValueOnce({ accounts: [TWO_ACCOUNTS.accounts[0]] });
257
+ const storage = new InMemoryStorage();
258
+ const manager = makeManager(services, storage);
259
+
260
+ await manager.restoreFromCookies();
261
+ await manager.signOutAuthuser(0);
262
+
263
+ expect(manager.getAccounts()).toEqual([]);
264
+ expect(manager.getActiveAuthuser()).toBeNull();
265
+ expect(manager.getActiveAccount()).toBeNull();
266
+ expect(services.httpService.setTokens).toHaveBeenLastCalledWith('');
267
+ expect(storage.has(ACTIVE_AUTHUSER_KEY)).toBe(false);
268
+ });
269
+
270
+ it('signs out a non-active slot without disturbing the active one', async () => {
271
+ const services = makeMockServices();
272
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
273
+ const storage = new InMemoryStorage();
274
+ const manager = makeManager(services, storage);
275
+
276
+ await manager.restoreFromCookies();
277
+ expect(manager.getActiveAuthuser()).toBe(0);
278
+ services.httpService.setTokens.mockClear();
279
+
280
+ await manager.signOutAuthuser(1);
281
+
282
+ expect(services.logoutSessionByAuthuser).toHaveBeenCalledWith(1);
283
+ expect(manager.getAccounts().map((a) => a.authuser)).toEqual([0]);
284
+ expect(manager.getActiveAuthuser()).toBe(0);
285
+ // Active slot's token must NOT be re-planted (it was never inactive).
286
+ expect(services.httpService.setTokens).not.toHaveBeenCalled();
287
+ });
288
+ });
289
+
290
+ describe('AuthManager.signOutAllViaCookies', () => {
291
+ it('clears every slot, the HTTP client token, and the persisted active authuser', async () => {
292
+ const services = makeMockServices();
293
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
294
+ const storage = new InMemoryStorage();
295
+ const manager = makeManager(services, storage);
296
+
297
+ await manager.restoreFromCookies();
298
+ await manager.signOutAllViaCookies();
299
+
300
+ expect(services.logoutAllSessionsViaCookie).toHaveBeenCalledTimes(1);
301
+ expect(manager.getAccounts()).toEqual([]);
302
+ expect(manager.getActiveAuthuser()).toBeNull();
303
+ expect(services.httpService.setTokens).toHaveBeenLastCalledWith('');
304
+ expect(storage.has(ACTIVE_AUTHUSER_KEY)).toBe(false);
305
+ });
306
+ });
307
+
308
+ describe('AuthManager.initialize (cookieOnly)', () => {
309
+ it('returns the active user from restoreFromCookies and never touches localStorage tokens', async () => {
310
+ const services = makeMockServices();
311
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
312
+ const storage = new InMemoryStorage();
313
+ const manager = makeManager(services, storage);
314
+
315
+ const user = await manager.initialize();
316
+
317
+ expect(user?.id).toBe('user-0');
318
+ expect(user?.username).toBe('alice');
319
+ expect(storage.has('oxy_access_token')).toBe(false);
320
+ expect(storage.has('oxy_session')).toBe(false);
321
+ expect(storage.has('oxy_user')).toBe(false);
322
+ });
323
+
324
+ it('returns null when no cookies AND cookieOnly mode (no legacy fallback)', async () => {
325
+ const services = makeMockServices();
326
+ // Default `{ accounts: [] }`.
327
+ const storage = new InMemoryStorage();
328
+ // Even if legacy token were present in storage, cookieOnly must skip it.
329
+ storage.setItem('oxy_access_token', 'stale-legacy-token');
330
+ storage.setItem('oxy_user', JSON.stringify({ id: 'legacy', username: 'legacy' }));
331
+ const manager = makeManager(services, storage);
332
+
333
+ const user = await manager.initialize();
334
+
335
+ expect(user).toBeNull();
336
+ // The legacy access token was NOT planted.
337
+ expect(services.httpService.setTokens).not.toHaveBeenCalled();
338
+ });
339
+ });