@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.
- package/README.md +5 -6
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +678 -4
- package/dist/cjs/AuthManagerTypes.js +13 -0
- package/dist/cjs/CrossDomainAuth.js +45 -3
- package/dist/cjs/OxyServices.base.js +16 -0
- package/dist/cjs/i18n/locales/ar-SA.json +83 -0
- package/dist/cjs/i18n/locales/ca-ES.json +83 -0
- package/dist/cjs/i18n/locales/de-DE.json +83 -0
- package/dist/cjs/i18n/locales/en-US.json +83 -0
- package/dist/cjs/i18n/locales/es-ES.json +99 -4
- package/dist/cjs/i18n/locales/fr-FR.json +83 -0
- package/dist/cjs/i18n/locales/it-IT.json +83 -0
- package/dist/cjs/i18n/locales/ja-JP.json +83 -0
- package/dist/cjs/i18n/locales/ko-KR.json +83 -0
- package/dist/cjs/i18n/locales/locales/ar-SA.json +83 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +83 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +83 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +83 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +99 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +83 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +83 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +200 -117
- package/dist/cjs/i18n/locales/locales/ko-KR.json +83 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +83 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +83 -1
- package/dist/cjs/i18n/locales/pt-PT.json +83 -0
- package/dist/cjs/i18n/locales/zh-CN.json +83 -0
- package/dist/cjs/index.js +121 -57
- package/dist/cjs/mixins/OxyServices.auth.js +235 -0
- package/dist/cjs/mixins/OxyServices.fedcm.js +36 -0
- package/dist/cjs/mixins/OxyServices.popup.js +61 -1
- package/dist/cjs/mixins/OxyServices.user.js +18 -0
- package/dist/cjs/utils/accountUtils.js +64 -1
- package/dist/cjs/utils/coldBoot.js +71 -0
- package/dist/cjs/utils/fapiAutoDetect.js +88 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +678 -4
- package/dist/esm/AuthManagerTypes.js +12 -0
- package/dist/esm/CrossDomainAuth.js +45 -3
- package/dist/esm/OxyServices.base.js +16 -0
- package/dist/esm/i18n/locales/ar-SA.json +83 -0
- package/dist/esm/i18n/locales/ca-ES.json +83 -0
- package/dist/esm/i18n/locales/de-DE.json +83 -0
- package/dist/esm/i18n/locales/en-US.json +83 -0
- package/dist/esm/i18n/locales/es-ES.json +99 -4
- package/dist/esm/i18n/locales/fr-FR.json +83 -0
- package/dist/esm/i18n/locales/it-IT.json +83 -0
- package/dist/esm/i18n/locales/ja-JP.json +83 -0
- package/dist/esm/i18n/locales/ko-KR.json +83 -0
- package/dist/esm/i18n/locales/locales/ar-SA.json +83 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +83 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +83 -1
- package/dist/esm/i18n/locales/locales/en-US.json +83 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +99 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +83 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +83 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +200 -117
- package/dist/esm/i18n/locales/locales/ko-KR.json +83 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +83 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +83 -1
- package/dist/esm/i18n/locales/pt-PT.json +83 -0
- package/dist/esm/i18n/locales/zh-CN.json +83 -0
- package/dist/esm/index.js +74 -26
- package/dist/esm/mixins/OxyServices.auth.js +235 -0
- package/dist/esm/mixins/OxyServices.fedcm.js +36 -0
- package/dist/esm/mixins/OxyServices.popup.js +61 -1
- package/dist/esm/mixins/OxyServices.user.js +18 -0
- package/dist/esm/utils/accountUtils.js +61 -0
- package/dist/esm/utils/coldBoot.js +68 -0
- package/dist/esm/utils/fapiAutoDetect.js +85 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/AuthManager.d.ts +243 -3
- package/dist/types/AuthManagerTypes.d.ts +68 -0
- package/dist/types/CrossDomainAuth.d.ts +23 -0
- package/dist/types/OxyServices.base.d.ts +14 -0
- package/dist/types/OxyServices.d.ts +7 -0
- package/dist/types/index.d.ts +31 -17
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +4 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +73 -1
- package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +2 -5
- package/dist/types/mixins/OxyServices.fedcm.d.ts +34 -0
- package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.popup.d.ts +40 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +16 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/dist/types/models/interfaces.d.ts +98 -0
- package/dist/types/models/session.d.ts +8 -0
- package/dist/types/utils/accountUtils.d.ts +33 -0
- package/dist/types/utils/coldBoot.d.ts +102 -0
- package/dist/types/utils/fapiAutoDetect.d.ts +37 -0
- package/package.json +9 -18
- package/src/AuthManager.ts +776 -7
- package/src/AuthManagerTypes.ts +72 -0
- package/src/CrossDomainAuth.ts +54 -3
- package/src/OxyServices.base.ts +17 -0
- package/src/OxyServices.ts +7 -0
- package/src/__tests__/authManager.cookiePath.test.ts +339 -0
- package/src/__tests__/authManager.security.test.ts +342 -0
- package/src/__tests__/crossDomainAuth.test.ts +191 -0
- package/src/i18n/locales/ar-SA.json +83 -1
- package/src/i18n/locales/ca-ES.json +83 -1
- package/src/i18n/locales/de-DE.json +83 -1
- package/src/i18n/locales/en-US.json +83 -0
- package/src/i18n/locales/es-ES.json +99 -4
- package/src/i18n/locales/fr-FR.json +83 -1
- package/src/i18n/locales/it-IT.json +83 -1
- package/src/i18n/locales/ja-JP.json +200 -117
- package/src/i18n/locales/ko-KR.json +83 -1
- package/src/i18n/locales/pt-PT.json +83 -1
- package/src/i18n/locales/zh-CN.json +83 -1
- package/src/index.ts +309 -112
- package/src/mixins/OxyServices.auth.ts +268 -1
- package/src/mixins/OxyServices.fedcm.ts +63 -0
- package/src/mixins/OxyServices.popup.ts +79 -1
- package/src/mixins/OxyServices.user.ts +33 -1
- package/src/mixins/__tests__/popup.test.ts +307 -0
- package/src/mixins/__tests__/sessionBaseUrl.test.ts +61 -0
- package/src/models/interfaces.ts +116 -0
- package/src/models/session.ts +8 -0
- package/src/utils/__tests__/coldBoot.test.ts +226 -0
- package/src/utils/__tests__/fapiAutoDetect.test.ts +93 -0
- package/src/utils/accountUtils.ts +84 -0
- package/src/utils/coldBoot.ts +136 -0
- package/src/utils/fapiAutoDetect.ts +82 -0
- package/dist/cjs/crypto/index.js +0 -22
- package/dist/cjs/shared/index.js +0 -70
- package/dist/cjs/utils/index.js +0 -26
- package/dist/esm/crypto/index.js +0 -13
- package/dist/esm/shared/index.js +0 -31
- package/dist/esm/utils/index.js +0 -7
- package/dist/types/crypto/index.d.ts +0 -11
- package/dist/types/shared/index.d.ts +0 -28
- package/dist/types/utils/index.d.ts +0 -6
- package/src/crypto/index.ts +0 -30
- package/src/shared/index.ts +0 -82
- 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
|
+
}
|
package/src/CrossDomainAuth.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
*/
|
package/src/OxyServices.base.ts
CHANGED
|
@@ -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
|
package/src/OxyServices.ts
CHANGED
|
@@ -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
|
+
});
|