@oxyhq/core 3.4.1 → 3.4.2
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +91 -319
- package/dist/cjs/CrossDomainAuth.js +19 -106
- package/dist/cjs/HttpService.js +49 -73
- package/dist/cjs/OxyServices.base.js +2 -2
- package/dist/cjs/i18n/index.js +7 -1
- package/dist/cjs/i18n/locales/ar-SA.json +18 -2
- package/dist/cjs/i18n/locales/ca-ES.json +18 -2
- package/dist/cjs/i18n/locales/de-DE.json +18 -2
- package/dist/cjs/i18n/locales/en-US.json +16 -2
- package/dist/cjs/i18n/locales/es-ES.json +16 -2
- package/dist/cjs/i18n/locales/fr-FR.json +18 -2
- package/dist/cjs/i18n/locales/it-IT.json +18 -2
- package/dist/cjs/i18n/locales/ja-JP.json +18 -2
- package/dist/cjs/i18n/locales/ko-KR.json +18 -2
- package/dist/cjs/i18n/locales/locales/ar-SA.json +18 -2
- package/dist/cjs/i18n/locales/locales/ca-ES.json +18 -2
- package/dist/cjs/i18n/locales/locales/de-DE.json +18 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +17 -3
- package/dist/cjs/i18n/locales/locales/es-ES.json +16 -2
- package/dist/cjs/i18n/locales/locales/fr-FR.json +18 -2
- package/dist/cjs/i18n/locales/locales/it-IT.json +18 -2
- package/dist/cjs/i18n/locales/locales/ja-JP.json +18 -2
- package/dist/cjs/i18n/locales/locales/ko-KR.json +18 -2
- package/dist/cjs/i18n/locales/locales/pt-PT.json +18 -2
- package/dist/cjs/i18n/locales/locales/zh-CN.json +18 -2
- package/dist/cjs/i18n/locales/pt-PT.json +18 -2
- package/dist/cjs/i18n/locales/zh-CN.json +18 -2
- package/dist/cjs/mixins/OxyServices.auth.js +20 -63
- package/dist/cjs/mixins/OxyServices.fedcm.js +10 -12
- package/dist/cjs/mixins/OxyServices.popup.js +50 -299
- package/dist/cjs/mixins/OxyServices.redirect.js +84 -348
- package/dist/cjs/mixins/OxyServices.silent.js +204 -0
- package/dist/cjs/mixins/OxyServices.sso.js +4 -5
- package/dist/cjs/mixins/OxyServices.utility.js +6 -15
- package/dist/cjs/mixins/index.js +5 -6
- package/dist/cjs/server/index.js +21 -0
- package/dist/cjs/server/rateLimit.js +77 -0
- package/dist/cjs/shared/utils/debugUtils.js +1 -1
- package/dist/cjs/utils/accountUtils.js +4 -4
- package/dist/cjs/utils/authHelpers.js +21 -15
- package/dist/cjs/utils/coldBoot.js +3 -3
- package/dist/cjs/utils/fapiAutoDetect.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +91 -319
- package/dist/esm/CrossDomainAuth.js +19 -106
- package/dist/esm/HttpService.js +49 -73
- package/dist/esm/OxyServices.base.js +2 -2
- package/dist/esm/i18n/index.js +7 -1
- package/dist/esm/i18n/locales/ar-SA.json +18 -2
- package/dist/esm/i18n/locales/ca-ES.json +18 -2
- package/dist/esm/i18n/locales/de-DE.json +18 -2
- package/dist/esm/i18n/locales/en-US.json +16 -2
- package/dist/esm/i18n/locales/es-ES.json +16 -2
- package/dist/esm/i18n/locales/fr-FR.json +18 -2
- package/dist/esm/i18n/locales/it-IT.json +18 -2
- package/dist/esm/i18n/locales/ja-JP.json +18 -2
- package/dist/esm/i18n/locales/ko-KR.json +18 -2
- package/dist/esm/i18n/locales/locales/ar-SA.json +18 -2
- package/dist/esm/i18n/locales/locales/ca-ES.json +18 -2
- package/dist/esm/i18n/locales/locales/de-DE.json +18 -2
- package/dist/esm/i18n/locales/locales/en-US.json +17 -3
- package/dist/esm/i18n/locales/locales/es-ES.json +16 -2
- package/dist/esm/i18n/locales/locales/fr-FR.json +18 -2
- package/dist/esm/i18n/locales/locales/it-IT.json +18 -2
- package/dist/esm/i18n/locales/locales/ja-JP.json +18 -2
- package/dist/esm/i18n/locales/locales/ko-KR.json +18 -2
- package/dist/esm/i18n/locales/locales/pt-PT.json +18 -2
- package/dist/esm/i18n/locales/locales/zh-CN.json +18 -2
- package/dist/esm/i18n/locales/pt-PT.json +18 -2
- package/dist/esm/i18n/locales/zh-CN.json +18 -2
- package/dist/esm/mixins/OxyServices.auth.js +20 -63
- package/dist/esm/mixins/OxyServices.fedcm.js +10 -12
- package/dist/esm/mixins/OxyServices.popup.js +52 -301
- package/dist/esm/mixins/OxyServices.redirect.js +84 -349
- package/dist/esm/mixins/OxyServices.silent.js +202 -0
- package/dist/esm/mixins/OxyServices.sso.js +4 -5
- package/dist/esm/mixins/OxyServices.utility.js +6 -15
- package/dist/esm/mixins/index.js +5 -6
- package/dist/esm/server/index.js +17 -0
- package/dist/esm/server/rateLimit.js +71 -0
- package/dist/esm/shared/utils/debugUtils.js +1 -1
- package/dist/esm/utils/accountUtils.js +4 -4
- package/dist/esm/utils/authHelpers.js +21 -15
- package/dist/esm/utils/coldBoot.js +3 -3
- package/dist/esm/utils/fapiAutoDetect.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/AuthManager.d.ts +26 -53
- package/dist/types/AuthManagerTypes.d.ts +5 -9
- package/dist/types/CrossDomainAuth.d.ts +13 -52
- package/dist/types/HttpService.d.ts +9 -8
- package/dist/types/OxyServices.base.d.ts +1 -1
- package/dist/types/OxyServices.d.ts +4 -10
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -1
- package/dist/types/mixins/OxyServices.appData.d.ts +1 -1
- package/dist/types/mixins/OxyServices.applications.d.ts +1 -1
- package/dist/types/mixins/OxyServices.assets.d.ts +1 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +10 -31
- package/dist/types/mixins/OxyServices.contacts.d.ts +1 -1
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -1
- package/dist/types/mixins/OxyServices.features.d.ts +1 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +5 -5
- package/dist/types/mixins/OxyServices.language.d.ts +1 -1
- package/dist/types/mixins/OxyServices.location.d.ts +1 -1
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -1
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -1
- package/dist/types/mixins/OxyServices.popup.d.ts +18 -120
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -1
- package/dist/types/mixins/OxyServices.redirect.d.ts +13 -174
- package/dist/types/mixins/OxyServices.reputation.d.ts +1 -1
- package/dist/types/mixins/OxyServices.security.d.ts +1 -1
- package/dist/types/mixins/OxyServices.silent.d.ts +131 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +4 -5
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -1
- package/dist/types/mixins/OxyServices.user.d.ts +1 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -8
- package/dist/types/mixins/OxyServices.workspaces.d.ts +1 -1
- package/dist/types/mixins/index.d.ts +3 -3
- package/dist/types/models/interfaces.d.ts +5 -16
- package/dist/types/models/session.d.ts +0 -2
- package/dist/types/server/index.d.ts +18 -0
- package/dist/types/server/rateLimit.d.ts +40 -0
- package/dist/types/shared/utils/debugUtils.d.ts +1 -1
- package/dist/types/utils/authHelpers.d.ts +4 -3
- package/dist/types/utils/coldBoot.d.ts +2 -2
- package/dist/types/utils/fapiAutoDetect.d.ts +1 -1
- package/package.json +24 -2
- package/src/AuthManager.ts +100 -370
- package/src/AuthManagerTypes.ts +5 -9
- package/src/CrossDomainAuth.ts +22 -129
- package/src/HttpService.ts +55 -73
- package/src/OxyServices.base.ts +2 -3
- package/src/OxyServices.ts +9 -11
- package/src/__tests__/authManager.cookiePath.test.ts +19 -17
- package/src/__tests__/authManager.security.test.ts +7 -3
- package/src/__tests__/crossDomainAuth.test.ts +26 -118
- package/src/i18n/index.ts +7 -1
- package/src/i18n/locales/ar-SA.json +18 -2
- package/src/i18n/locales/ca-ES.json +18 -2
- package/src/i18n/locales/de-DE.json +18 -2
- package/src/i18n/locales/en-US.json +17 -3
- package/src/i18n/locales/es-ES.json +16 -2
- package/src/i18n/locales/fr-FR.json +18 -2
- package/src/i18n/locales/it-IT.json +18 -2
- package/src/i18n/locales/ja-JP.json +18 -2
- package/src/i18n/locales/ko-KR.json +18 -2
- package/src/i18n/locales/pt-PT.json +18 -2
- package/src/i18n/locales/zh-CN.json +18 -2
- package/src/index.ts +1 -1
- package/src/mixins/OxyServices.auth.ts +23 -75
- package/src/mixins/OxyServices.fedcm.ts +10 -12
- package/src/mixins/OxyServices.redirect.ts +82 -371
- package/src/mixins/OxyServices.silent.ts +272 -0
- package/src/mixins/OxyServices.sso.ts +5 -6
- package/src/mixins/OxyServices.utility.ts +9 -22
- package/src/mixins/__tests__/appData.test.ts +1 -1
- package/src/mixins/__tests__/onTokensChanged.test.ts +1 -1
- package/src/mixins/__tests__/reputation.test.ts +1 -1
- package/src/mixins/__tests__/serviceAuth.test.ts +7 -5
- package/src/mixins/__tests__/silent.test.ts +102 -0
- package/src/mixins/__tests__/verifyChallenge.test.ts +9 -14
- package/src/mixins/index.ts +6 -8
- package/src/models/interfaces.ts +5 -16
- package/src/models/session.ts +1 -3
- package/src/server/index.ts +19 -0
- package/src/server/rateLimit.ts +170 -0
- package/src/shared/utils/debugUtils.ts +1 -1
- package/src/utils/accountUtils.ts +4 -4
- package/src/utils/authHelpers.ts +23 -15
- package/src/utils/coldBoot.ts +4 -4
- package/src/utils/fapiAutoDetect.ts +1 -1
- package/src/mixins/OxyServices.popup.ts +0 -631
- package/src/mixins/__tests__/popup.test.ts +0 -374
package/src/AuthManagerTypes.ts
CHANGED
|
@@ -30,13 +30,10 @@ export interface AuthManagerAccount {
|
|
|
30
30
|
* Projected user shape from the wire (username/avatar/color/email).
|
|
31
31
|
*
|
|
32
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
33
|
+
* that the AuthManager has no prior in-memory user metadata for. Callers (or
|
|
34
|
+
* the AuthManager itself) are expected to hydrate the user shape via
|
|
35
|
+
* `getCurrentUser()` after the token is planted; the chooser UI must render
|
|
36
|
+
* the public-key fallback handle until the hydration completes.
|
|
40
37
|
*/
|
|
41
38
|
user: RefreshAllAccountUser | null;
|
|
42
39
|
/** Currently-valid access token for this slot (in-memory only). */
|
|
@@ -80,8 +77,7 @@ export interface RestoreFromCookiesOptions {
|
|
|
80
77
|
/**
|
|
81
78
|
* Outcome of `AuthManager.switchAuthuser()`.
|
|
82
79
|
*
|
|
83
|
-
* Mirrors the wire `RefreshCookieResponse
|
|
84
|
-
* `number` (the SDK boundary normalises the legacy `null` slot to `0`).
|
|
80
|
+
* Mirrors the wire `RefreshCookieResponse`.
|
|
85
81
|
*/
|
|
86
82
|
export interface SwitchAuthuserResult {
|
|
87
83
|
accessToken: string;
|
package/src/CrossDomainAuth.ts
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* selects the best authentication method based on browser capabilities:
|
|
6
6
|
*
|
|
7
7
|
* 1. FedCM (if supported) - Modern, Google-style browser-native auth
|
|
8
|
-
* 2.
|
|
9
|
-
* 3. Redirect (final fallback) - Traditional full-page redirect
|
|
8
|
+
* 2. Redirect (fallback) - Tokenless central SSO full-page redirect
|
|
10
9
|
*
|
|
11
10
|
* Usage:
|
|
12
11
|
* ```typescript
|
|
@@ -17,8 +16,8 @@
|
|
|
17
16
|
* // Automatic method selection
|
|
18
17
|
* const session = await auth.signIn();
|
|
19
18
|
*
|
|
20
|
-
* // Or use specific method
|
|
21
|
-
*
|
|
19
|
+
* // Or use a specific method
|
|
20
|
+
* auth.signInWithRedirect();
|
|
22
21
|
* ```
|
|
23
22
|
*/
|
|
24
23
|
|
|
@@ -31,10 +30,9 @@ export interface CrossDomainAuthOptions {
|
|
|
31
30
|
* Preferred authentication method
|
|
32
31
|
* - 'auto': Automatically select best method (default)
|
|
33
32
|
* - 'fedcm': Use FedCM (browser-native)
|
|
34
|
-
* - 'popup': Use popup window
|
|
35
33
|
* - 'redirect': Use full-page redirect
|
|
36
34
|
*/
|
|
37
|
-
method?: 'auto' | 'fedcm' | '
|
|
35
|
+
method?: 'auto' | 'fedcm' | 'redirect';
|
|
38
36
|
|
|
39
37
|
/**
|
|
40
38
|
* Custom redirect URI (for redirect method)
|
|
@@ -46,26 +44,10 @@ export interface CrossDomainAuthOptions {
|
|
|
46
44
|
*/
|
|
47
45
|
isSignup?: boolean;
|
|
48
46
|
|
|
49
|
-
/**
|
|
50
|
-
* Popup window dimensions (for popup method)
|
|
51
|
-
*/
|
|
52
|
-
popupDimensions?: {
|
|
53
|
-
width?: number;
|
|
54
|
-
height?: number;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
47
|
/**
|
|
58
48
|
* Callback when auth method is selected
|
|
59
49
|
*/
|
|
60
|
-
onMethodSelected?: (method: 'fedcm' | '
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* A popup window the caller already opened SYNCHRONOUSLY in the user-gesture
|
|
64
|
-
* handler. Forwarded to `OxyServices.signInWithPopup` so the popup is not
|
|
65
|
-
* blocked by Chrome after any prior `await` (FedCM / silent SSO) has
|
|
66
|
-
* consumed the transient user activation. See `OxyServices.openBlankPopup`.
|
|
67
|
-
*/
|
|
68
|
-
popup?: Window | null;
|
|
50
|
+
onMethodSelected?: (method: 'fedcm' | 'redirect') => void;
|
|
69
51
|
}
|
|
70
52
|
|
|
71
53
|
export class CrossDomainAuth {
|
|
@@ -76,8 +58,7 @@ export class CrossDomainAuth {
|
|
|
76
58
|
*
|
|
77
59
|
* Tries methods in this order:
|
|
78
60
|
* 1. FedCM (if supported and not in private browsing)
|
|
79
|
-
* 2.
|
|
80
|
-
* 3. Redirect (always works)
|
|
61
|
+
* 2. Redirect (always works)
|
|
81
62
|
*
|
|
82
63
|
* @param options - Authentication options
|
|
83
64
|
* @returns Session with user data and access token
|
|
@@ -85,49 +66,19 @@ export class CrossDomainAuth {
|
|
|
85
66
|
async signIn(options: CrossDomainAuthOptions = {}): Promise<SessionLoginResponse | null> {
|
|
86
67
|
const method = options.method || 'auto';
|
|
87
68
|
|
|
88
|
-
// If specific method requested, use it directly. The caller MAY have
|
|
89
|
-
// pre-opened a popup on the raw click (the standard pattern in
|
|
90
|
-
// WebOxyProvider / services useAuth). For the FedCM and redirect paths
|
|
91
|
-
// that popup is unused — close it so it doesn't linger as an orphaned
|
|
92
|
-
// blank window. Close in both success and failure paths.
|
|
93
69
|
if (method === 'fedcm') {
|
|
94
|
-
|
|
95
|
-
const session = await this.signInWithFedCM(options);
|
|
96
|
-
this.closeOrphanPopup(options.popup);
|
|
97
|
-
return session;
|
|
98
|
-
} catch (error) {
|
|
99
|
-
this.closeOrphanPopup(options.popup);
|
|
100
|
-
throw error;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (method === 'popup') {
|
|
105
|
-
return this.signInWithPopup(options);
|
|
70
|
+
return this.signInWithFedCM(options);
|
|
106
71
|
}
|
|
107
72
|
|
|
108
73
|
if (method === 'redirect') {
|
|
109
|
-
this.closeOrphanPopup(options.popup);
|
|
110
74
|
this.signInWithRedirect(options);
|
|
111
75
|
return null; // Redirect doesn't return immediately
|
|
112
76
|
}
|
|
113
77
|
|
|
114
|
-
// Auto mode:
|
|
78
|
+
// Auto mode: try methods in order of preference.
|
|
115
79
|
return this.autoSignIn(options);
|
|
116
80
|
}
|
|
117
81
|
|
|
118
|
-
/**
|
|
119
|
-
* Close a caller-supplied popup window that is no longer needed (e.g. the
|
|
120
|
-
* resolved auth method didn't end up using it). Safe against null / already
|
|
121
|
-
* closed handles.
|
|
122
|
-
*
|
|
123
|
-
* @private
|
|
124
|
-
*/
|
|
125
|
-
private closeOrphanPopup(popup: Window | null | undefined): void {
|
|
126
|
-
if (popup && !popup.closed) {
|
|
127
|
-
popup.close();
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
82
|
/**
|
|
132
83
|
* Automatic sign-in with progressive enhancement
|
|
133
84
|
*
|
|
@@ -138,27 +89,13 @@ export class CrossDomainAuth {
|
|
|
138
89
|
if (this.isFedCMSupported()) {
|
|
139
90
|
try {
|
|
140
91
|
options.onMethodSelected?.('fedcm');
|
|
141
|
-
|
|
142
|
-
// FedCM succeeded — close the pre-opened popup so it doesn't linger
|
|
143
|
-
// as an orphaned blank window.
|
|
144
|
-
this.closeOrphanPopup(options.popup);
|
|
145
|
-
return session;
|
|
92
|
+
return await this.signInWithFedCM(options);
|
|
146
93
|
} catch (error) {
|
|
147
|
-
logger.warn('FedCM failed,
|
|
94
|
+
logger.warn('FedCM failed, falling back to redirect', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
|
|
148
95
|
}
|
|
149
96
|
}
|
|
150
97
|
|
|
151
|
-
// 2.
|
|
152
|
-
try {
|
|
153
|
-
options.onMethodSelected?.('popup');
|
|
154
|
-
return await this.signInWithPopup(options);
|
|
155
|
-
} catch (error) {
|
|
156
|
-
logger.warn('Popup failed, falling back to redirect', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
|
|
157
|
-
// Popup path failed — close the pre-opened popup before redirecting.
|
|
158
|
-
this.closeOrphanPopup(options.popup);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// 3. Fallback to redirect (always works)
|
|
98
|
+
// 2. Fallback to redirect (always works)
|
|
162
99
|
options.onMethodSelected?.('redirect');
|
|
163
100
|
this.signInWithRedirect(options);
|
|
164
101
|
return null;
|
|
@@ -167,7 +104,7 @@ export class CrossDomainAuth {
|
|
|
167
104
|
/**
|
|
168
105
|
* Sign in using FedCM (Federated Credential Management)
|
|
169
106
|
*
|
|
170
|
-
* Best method - browser-native,
|
|
107
|
+
* Best method - browser-native, Google-like experience
|
|
171
108
|
*/
|
|
172
109
|
async signInWithFedCM(options: CrossDomainAuthOptions = {}): Promise<SessionLoginResponse> {
|
|
173
110
|
return this.oxyServices.signInWithFedCM({
|
|
@@ -175,20 +112,6 @@ export class CrossDomainAuth {
|
|
|
175
112
|
});
|
|
176
113
|
}
|
|
177
114
|
|
|
178
|
-
/**
|
|
179
|
-
* Sign in using popup window
|
|
180
|
-
*
|
|
181
|
-
* Good method - preserves app state, no full page reload
|
|
182
|
-
*/
|
|
183
|
-
async signInWithPopup(options: CrossDomainAuthOptions = {}): Promise<SessionLoginResponse> {
|
|
184
|
-
return this.oxyServices.signInWithPopup({
|
|
185
|
-
mode: options.isSignup ? 'signup' : 'login',
|
|
186
|
-
width: options.popupDimensions?.width,
|
|
187
|
-
height: options.popupDimensions?.height,
|
|
188
|
-
popup: options.popup ?? undefined,
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
115
|
/**
|
|
193
116
|
* Sign in using full-page redirect
|
|
194
117
|
*
|
|
@@ -214,7 +137,7 @@ export class CrossDomainAuth {
|
|
|
214
137
|
* Silent sign-in (check for existing session)
|
|
215
138
|
*
|
|
216
139
|
* Tries to automatically sign in without user interaction.
|
|
217
|
-
* Works with
|
|
140
|
+
* Works with FedCM and iframe-based silent auth.
|
|
218
141
|
*
|
|
219
142
|
* @returns Session if user is already signed in, null otherwise
|
|
220
143
|
*/
|
|
@@ -241,23 +164,13 @@ export class CrossDomainAuth {
|
|
|
241
164
|
}
|
|
242
165
|
|
|
243
166
|
/**
|
|
244
|
-
* Restore session from storage
|
|
167
|
+
* Restore session from storage.
|
|
245
168
|
*
|
|
246
|
-
*
|
|
169
|
+
* Access tokens are no longer persisted in browser storage; providers restore
|
|
170
|
+
* through refresh cookies / SSO code exchange instead.
|
|
247
171
|
*/
|
|
248
172
|
restoreSession(): boolean {
|
|
249
|
-
return
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Open a blank popup SYNCHRONOUSLY (call from a raw user-gesture handler
|
|
254
|
-
* BEFORE any `await`). Returns `null` if the popup was blocked. Pass the
|
|
255
|
-
* handle into `signIn({ popup })` / `signInWithPopup({ popup })` so the
|
|
256
|
-
* popup is not blocked by Chrome after any prior `await` consumed the
|
|
257
|
-
* transient user activation. Delegates to `OxyServices.openBlankPopup`.
|
|
258
|
-
*/
|
|
259
|
-
openBlankPopup(width?: number, height?: number): Window | null {
|
|
260
|
-
return this.oxyServices.openBlankPopup(width, height);
|
|
173
|
+
return false;
|
|
261
174
|
}
|
|
262
175
|
|
|
263
176
|
/**
|
|
@@ -274,7 +187,7 @@ export class CrossDomainAuth {
|
|
|
274
187
|
*
|
|
275
188
|
* @returns Recommended method name and reason
|
|
276
189
|
*/
|
|
277
|
-
getRecommendedMethod(): { method: 'fedcm' | '
|
|
190
|
+
getRecommendedMethod(): { method: 'fedcm' | 'redirect'; reason: string } {
|
|
278
191
|
if (this.isFedCMSupported()) {
|
|
279
192
|
return {
|
|
280
193
|
method: 'fedcm',
|
|
@@ -284,8 +197,8 @@ export class CrossDomainAuth {
|
|
|
284
197
|
|
|
285
198
|
if (typeof window !== 'undefined') {
|
|
286
199
|
return {
|
|
287
|
-
method: '
|
|
288
|
-
reason: 'Browser environment -
|
|
200
|
+
method: 'redirect',
|
|
201
|
+
reason: 'Browser environment - redirect SSO works without token callback URLs',
|
|
289
202
|
};
|
|
290
203
|
}
|
|
291
204
|
|
|
@@ -300,8 +213,7 @@ export class CrossDomainAuth {
|
|
|
300
213
|
*
|
|
301
214
|
* This handles:
|
|
302
215
|
* 1. Redirect callback (if returning from auth.oxy.so)
|
|
303
|
-
* 2.
|
|
304
|
-
* 3. Silent sign-in (check for existing SSO session)
|
|
216
|
+
* 2. Silent sign-in (check for existing SSO session)
|
|
305
217
|
*
|
|
306
218
|
* @returns Session if user is authenticated, null otherwise
|
|
307
219
|
*/
|
|
@@ -312,26 +224,7 @@ export class CrossDomainAuth {
|
|
|
312
224
|
return callbackSession;
|
|
313
225
|
}
|
|
314
226
|
|
|
315
|
-
// 2. Try
|
|
316
|
-
const restored = this.restoreSession();
|
|
317
|
-
if (restored) {
|
|
318
|
-
// Verify session is still valid by fetching user
|
|
319
|
-
try {
|
|
320
|
-
const user = await this.oxyServices.getCurrentUser();
|
|
321
|
-
if (user) {
|
|
322
|
-
return {
|
|
323
|
-
sessionId: this.oxyServices.getStoredSessionId?.() || '',
|
|
324
|
-
deviceId: '',
|
|
325
|
-
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
326
|
-
user,
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
} catch (error) {
|
|
330
|
-
logger.debug('stored session invalid', { component: 'CrossDomainAuth', method: 'initialize' }, error);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// 3. Try silent sign-in (check for SSO session at auth.oxy.so)
|
|
227
|
+
// 2. Try silent sign-in (check for SSO session at auth.oxy.so)
|
|
335
228
|
return await this.silentSignIn();
|
|
336
229
|
}
|
|
337
230
|
}
|
package/src/HttpService.ts
CHANGED
|
@@ -35,6 +35,9 @@ interface JwtPayload {
|
|
|
35
35
|
[key: string]: any;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export type AuthRefreshReason = 'preflight' | 'response-401';
|
|
39
|
+
export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
|
|
40
|
+
|
|
38
41
|
/**
|
|
39
42
|
* Structural type that captures the multipart-write surface every supported
|
|
40
43
|
* FormData implementation exposes (browser, React Native, Node `form-data`
|
|
@@ -110,26 +113,19 @@ interface RequestConfig extends RequestOptions {
|
|
|
110
113
|
*/
|
|
111
114
|
class TokenStore {
|
|
112
115
|
private accessToken: string | null = null;
|
|
113
|
-
private refreshToken: string | null = null;
|
|
114
116
|
private csrfToken: string | null = null;
|
|
115
117
|
private csrfTokenFetchPromise: Promise<string | null> | null = null;
|
|
116
118
|
|
|
117
|
-
setTokens(accessToken: string
|
|
119
|
+
setTokens(accessToken: string): void {
|
|
118
120
|
this.accessToken = accessToken;
|
|
119
|
-
this.refreshToken = refreshToken;
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
getAccessToken(): string | null {
|
|
123
124
|
return this.accessToken;
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
getRefreshToken(): string | null {
|
|
127
|
-
return this.refreshToken;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
127
|
clearTokens(): void {
|
|
131
128
|
this.accessToken = null;
|
|
132
|
-
this.refreshToken = null;
|
|
133
129
|
}
|
|
134
130
|
|
|
135
131
|
hasAccessToken(): boolean {
|
|
@@ -174,14 +170,13 @@ export class HttpService {
|
|
|
174
170
|
private config: OxyConfig;
|
|
175
171
|
private tokenRefreshPromise: Promise<string | null> | null = null;
|
|
176
172
|
private tokenRefreshCooldownUntil: number = 0;
|
|
177
|
-
private
|
|
173
|
+
private authRefreshHandler: AuthRefreshHandler | null = null;
|
|
178
174
|
|
|
179
175
|
/**
|
|
180
176
|
* Fan-out listeners notified on EVERY access-token change on this instance:
|
|
181
|
-
* explicit `setTokens`, `clearTokens`,
|
|
182
|
-
* internal 401-driven clear.
|
|
183
|
-
*
|
|
184
|
-
* independent observers can mirror token state without clobbering each other.
|
|
177
|
+
* explicit `setTokens`, `clearTokens`, an AuthManager-owned refresh, and the
|
|
178
|
+
* internal 401-driven clear. This is a Set so multiple independent observers
|
|
179
|
+
* can mirror token state without clobbering each other.
|
|
185
180
|
*
|
|
186
181
|
* Each listener receives the resulting access token, or `null` when cleared.
|
|
187
182
|
*/
|
|
@@ -421,22 +416,13 @@ export class HttpService {
|
|
|
421
416
|
|
|
422
417
|
// Handle response
|
|
423
418
|
if (!response.ok) {
|
|
424
|
-
// On 401,
|
|
419
|
+
// On 401, delegate refresh to AuthManager and retry once before
|
|
420
|
+
// giving up. HttpService deliberately does not know any session
|
|
421
|
+
// routes; the AuthManager is the single session authority.
|
|
425
422
|
if (response.status === 401 && !config._isAuthRetry) {
|
|
426
|
-
const
|
|
427
|
-
if (
|
|
428
|
-
|
|
429
|
-
const decoded = jwtDecode<JwtPayload>(currentToken);
|
|
430
|
-
if (decoded.sessionId) {
|
|
431
|
-
const refreshResult = await this._refreshTokenFromSession(decoded.sessionId);
|
|
432
|
-
if (refreshResult) {
|
|
433
|
-
// Retry the request with the new token
|
|
434
|
-
return this.request<T>({ ...config, _isAuthRetry: true, retry: false });
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
} catch {
|
|
438
|
-
// Token decode failed, fall through to clear
|
|
439
|
-
}
|
|
423
|
+
const refreshed = await this.refreshAccessToken('response-401');
|
|
424
|
+
if (refreshed) {
|
|
425
|
+
return this.request<T>({ ...config, _isAuthRetry: true, retry: false });
|
|
440
426
|
}
|
|
441
427
|
// Refresh failed or no token — clear tokens and stale CSRF
|
|
442
428
|
this.tokenStore.clearTokens();
|
|
@@ -464,7 +450,7 @@ export class HttpService {
|
|
|
464
450
|
if (contentType && contentType.includes('application/json')) {
|
|
465
451
|
try {
|
|
466
452
|
const errorData = await response.json() as { message?: string; error?: string } | null;
|
|
467
|
-
//
|
|
453
|
+
// Accept either structured error field from API responses.
|
|
468
454
|
if (errorData?.message) {
|
|
469
455
|
errorMessage = errorData.message;
|
|
470
456
|
} else if (errorData?.error) {
|
|
@@ -878,24 +864,9 @@ export class HttpService {
|
|
|
878
864
|
const currentTime = Math.floor(Date.now() / 1000);
|
|
879
865
|
|
|
880
866
|
// If token expires in less than 60 seconds, refresh it
|
|
881
|
-
if (decoded.exp && decoded.exp - currentTime < 60
|
|
882
|
-
|
|
883
|
-
if (
|
|
884
|
-
return `Bearer ${accessToken}`;
|
|
885
|
-
}
|
|
886
|
-
// Deduplicate concurrent refresh attempts. The promise is shared
|
|
887
|
-
// across all concurrent callers and cleared only after it settles,
|
|
888
|
-
// so every awaiter receives the same result.
|
|
889
|
-
if (!this.tokenRefreshPromise) {
|
|
890
|
-
this.tokenRefreshPromise = this._refreshTokenFromSession(decoded.sessionId)
|
|
891
|
-
.then((result) => {
|
|
892
|
-
if (!result) this.tokenRefreshCooldownUntil = Date.now() + 15000;
|
|
893
|
-
return result;
|
|
894
|
-
})
|
|
895
|
-
.finally(() => { this.tokenRefreshPromise = null; });
|
|
896
|
-
}
|
|
897
|
-
const result = await this.tokenRefreshPromise;
|
|
898
|
-
if (result) return result;
|
|
867
|
+
if (decoded.exp && decoded.exp - currentTime < 60) {
|
|
868
|
+
const refreshed = await this.refreshAccessToken('preflight');
|
|
869
|
+
if (refreshed) return `Bearer ${refreshed}`;
|
|
899
870
|
// Refresh failed — don't use the expired token (would cause 401 loop)
|
|
900
871
|
return null;
|
|
901
872
|
}
|
|
@@ -907,28 +878,40 @@ export class HttpService {
|
|
|
907
878
|
}
|
|
908
879
|
}
|
|
909
880
|
|
|
910
|
-
private async
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
const response = await fetch(refreshUrl, {
|
|
914
|
-
method: 'GET',
|
|
915
|
-
headers: { 'Accept': 'application/json' },
|
|
916
|
-
signal: AbortSignal.timeout(5000),
|
|
917
|
-
credentials: 'include',
|
|
918
|
-
});
|
|
919
|
-
|
|
920
|
-
if (response.ok) {
|
|
921
|
-
const { accessToken: newToken } = await response.json();
|
|
922
|
-
this.tokenStore.setTokens(newToken);
|
|
923
|
-
this._onTokenRefreshed?.(newToken);
|
|
924
|
-
this.notifyTokenChange();
|
|
925
|
-
this.logger.debug('Token refreshed');
|
|
926
|
-
return `Bearer ${newToken}`;
|
|
927
|
-
}
|
|
928
|
-
} catch (refreshError) {
|
|
929
|
-
this.logger.warn('Token refresh failed, using current token');
|
|
881
|
+
private async refreshAccessToken(reason: AuthRefreshReason): Promise<string | null> {
|
|
882
|
+
if (!this.authRefreshHandler) {
|
|
883
|
+
return null;
|
|
930
884
|
}
|
|
931
|
-
|
|
885
|
+
|
|
886
|
+
if (Date.now() < this.tokenRefreshCooldownUntil) {
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (!this.tokenRefreshPromise) {
|
|
891
|
+
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
892
|
+
.then((newToken) => {
|
|
893
|
+
if (!newToken) {
|
|
894
|
+
this.tokenRefreshCooldownUntil = Date.now() + 15000;
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
if (this.tokenStore.getAccessToken() !== newToken) {
|
|
898
|
+
this.tokenStore.setTokens(newToken);
|
|
899
|
+
this.notifyTokenChange();
|
|
900
|
+
}
|
|
901
|
+
this.logger.debug('Token refreshed via AuthManager');
|
|
902
|
+
return newToken;
|
|
903
|
+
})
|
|
904
|
+
.catch((error) => {
|
|
905
|
+
this.logger.warn('Token refresh failed:', error);
|
|
906
|
+
this.tokenRefreshCooldownUntil = Date.now() + 15000;
|
|
907
|
+
return null;
|
|
908
|
+
})
|
|
909
|
+
.finally(() => {
|
|
910
|
+
this.tokenRefreshPromise = null;
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return this.tokenRefreshPromise;
|
|
932
915
|
}
|
|
933
916
|
|
|
934
917
|
/**
|
|
@@ -996,13 +979,13 @@ export class HttpService {
|
|
|
996
979
|
}
|
|
997
980
|
|
|
998
981
|
// Token management
|
|
999
|
-
setTokens(accessToken: string
|
|
1000
|
-
this.tokenStore.setTokens(accessToken
|
|
982
|
+
setTokens(accessToken: string): void {
|
|
983
|
+
this.tokenStore.setTokens(accessToken);
|
|
1001
984
|
this.notifyTokenChange();
|
|
1002
985
|
}
|
|
1003
986
|
|
|
1004
|
-
|
|
1005
|
-
this.
|
|
987
|
+
setAuthRefreshHandler(handler: AuthRefreshHandler | null): void {
|
|
988
|
+
this.authRefreshHandler = handler;
|
|
1006
989
|
}
|
|
1007
990
|
|
|
1008
991
|
clearTokens(): void {
|
|
@@ -1136,4 +1119,3 @@ export class HttpService {
|
|
|
1136
1119
|
this.tokenStore.clearCsrfToken();
|
|
1137
1120
|
}
|
|
1138
1121
|
}
|
|
1139
|
-
|
package/src/OxyServices.base.ts
CHANGED
|
@@ -164,8 +164,8 @@ export class OxyServicesBase {
|
|
|
164
164
|
/**
|
|
165
165
|
* Set authentication tokens
|
|
166
166
|
*/
|
|
167
|
-
public setTokens(accessToken: string
|
|
168
|
-
this.httpService.setTokens(accessToken
|
|
167
|
+
public setTokens(accessToken: string): void {
|
|
168
|
+
this.httpService.setTokens(accessToken);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/**
|
|
@@ -430,4 +430,3 @@ export class OxyServicesBase {
|
|
|
430
430
|
}
|
|
431
431
|
}
|
|
432
432
|
}
|
|
433
|
-
|
package/src/OxyServices.ts
CHANGED
|
@@ -60,7 +60,7 @@ import { OxyServicesBase, type OxyConfig } from './OxyServices.base';
|
|
|
60
60
|
import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
|
|
61
61
|
import type { SessionLoginResponse } from './models/session';
|
|
62
62
|
import type { FedCMAuthOptions, FedCMConfig } from './mixins/OxyServices.fedcm';
|
|
63
|
-
import type {
|
|
63
|
+
import type { SilentAuthOptions } from './mixins/OxyServices.silent';
|
|
64
64
|
import type { RedirectAuthOptions } from './mixins/OxyServices.redirect';
|
|
65
65
|
|
|
66
66
|
// Import mixin composition helper
|
|
@@ -137,16 +137,14 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
137
137
|
revokeFedCMCredential(): Promise<void>;
|
|
138
138
|
getFedCMConfig(): FedCMConfig;
|
|
139
139
|
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
*/
|
|
149
|
-
openBlankPopup(width?: number, height?: number): Window | null;
|
|
140
|
+
// Silent iframe SSO
|
|
141
|
+
resolveAuthUrl(): string;
|
|
142
|
+
silentSignIn(options?: SilentAuthOptions): Promise<SessionLoginResponse | null>;
|
|
143
|
+
waitForIframeAuth(
|
|
144
|
+
iframe: HTMLIFrameElement,
|
|
145
|
+
timeout: number,
|
|
146
|
+
expectedOrigin: string,
|
|
147
|
+
): Promise<SessionLoginResponse | null>;
|
|
150
148
|
|
|
151
149
|
// Redirect authentication
|
|
152
150
|
signInWithRedirect(options?: RedirectAuthOptions): void;
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AuthManager multi-account cookie-path regression tests.
|
|
3
3
|
*
|
|
4
|
-
* Locks in the four
|
|
5
|
-
* `oxy_rt_${authuser}` refresh cookies
|
|
6
|
-
* `/session/token/:id` endpoint:
|
|
4
|
+
* Locks in the four methods that route through the httpOnly
|
|
5
|
+
* `oxy_rt_${authuser}` refresh cookies:
|
|
7
6
|
*
|
|
8
7
|
* - `restoreFromCookies()` — cold-boot restore of every device-local slot
|
|
9
8
|
* via `POST /auth/refresh-all`. Picks active slot by persisted
|
|
@@ -44,12 +43,17 @@ class InMemoryStorage implements StorageAdapter {
|
|
|
44
43
|
raw(): Map<string, string> { return this.store; }
|
|
45
44
|
}
|
|
46
45
|
|
|
46
|
+
interface MockHttpService {
|
|
47
|
+
setTokens: jest.Mock;
|
|
48
|
+
setAuthRefreshHandler: jest.Mock;
|
|
49
|
+
}
|
|
50
|
+
|
|
47
51
|
interface MockServices {
|
|
48
52
|
refreshAllSessions: jest.Mock<Promise<RefreshAllResponse>, []>;
|
|
49
53
|
refreshTokenViaCookie: jest.Mock;
|
|
50
54
|
logoutSessionByAuthuser: jest.Mock<Promise<void>, [number]>;
|
|
51
55
|
logoutAllSessionsViaCookie: jest.Mock<Promise<void>, []>;
|
|
52
|
-
httpService:
|
|
56
|
+
httpService: MockHttpService;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
function makeMockServices(): MockServices {
|
|
@@ -58,7 +62,7 @@ function makeMockServices(): MockServices {
|
|
|
58
62
|
refreshTokenViaCookie: jest.fn(),
|
|
59
63
|
logoutSessionByAuthuser: jest.fn(async () => undefined),
|
|
60
64
|
logoutAllSessionsViaCookie: jest.fn(async () => undefined),
|
|
61
|
-
httpService: { setTokens: jest.fn(),
|
|
65
|
+
httpService: { setTokens: jest.fn(), setAuthRefreshHandler: jest.fn() },
|
|
62
66
|
};
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -68,7 +72,6 @@ function makeManager(services: MockServices, storage: InMemoryStorage): AuthMana
|
|
|
68
72
|
storage,
|
|
69
73
|
autoRefresh: false,
|
|
70
74
|
crossTabSync: false,
|
|
71
|
-
cookieOnly: true,
|
|
72
75
|
});
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -305,7 +308,7 @@ describe('AuthManager.signOutAllViaCookies', () => {
|
|
|
305
308
|
});
|
|
306
309
|
});
|
|
307
310
|
|
|
308
|
-
describe('AuthManager.initialize
|
|
311
|
+
describe('AuthManager.initialize', () => {
|
|
309
312
|
it('returns the active user from restoreFromCookies and never touches localStorage tokens', async () => {
|
|
310
313
|
const services = makeMockServices();
|
|
311
314
|
services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
|
|
@@ -321,24 +324,23 @@ describe('AuthManager.initialize (cookieOnly)', () => {
|
|
|
321
324
|
expect(storage.has('oxy_user')).toBe(false);
|
|
322
325
|
});
|
|
323
326
|
|
|
324
|
-
it('returns null when no cookies
|
|
327
|
+
it('returns null when no cookies are restored', async () => {
|
|
325
328
|
const services = makeMockServices();
|
|
326
329
|
// Default `{ accounts: [] }`.
|
|
327
330
|
const storage = new InMemoryStorage();
|
|
328
|
-
|
|
329
|
-
storage.setItem('
|
|
330
|
-
storage.setItem('oxy_user', JSON.stringify({ id: 'legacy', username: 'legacy' }));
|
|
331
|
+
storage.setItem('oxy_access_token', 'stale-storage-token');
|
|
332
|
+
storage.setItem('oxy_user', JSON.stringify({ id: 'stale', username: 'stale' }));
|
|
331
333
|
const manager = makeManager(services, storage);
|
|
332
334
|
|
|
333
335
|
const user = await manager.initialize();
|
|
334
336
|
|
|
335
337
|
expect(user).toBeNull();
|
|
336
|
-
//
|
|
338
|
+
// Stale storage token material is ignored.
|
|
337
339
|
expect(services.httpService.setTokens).not.toHaveBeenCalled();
|
|
338
340
|
});
|
|
339
341
|
});
|
|
340
342
|
|
|
341
|
-
describe('AuthManager.getAccessToken
|
|
343
|
+
describe('AuthManager.getAccessToken', () => {
|
|
342
344
|
it('returns the in-memory token after a cold-boot cookie restore even though storage holds no token', async () => {
|
|
343
345
|
// Regression: the cookie restore path plants the active token ONLY in memory
|
|
344
346
|
// (`_lastKnownAccessToken` + httpService) and intentionally NEVER writes
|
|
@@ -352,7 +354,7 @@ describe('AuthManager.getAccessToken (cookieOnly in-memory fallback)', () => {
|
|
|
352
354
|
|
|
353
355
|
await manager.restoreFromCookies();
|
|
354
356
|
|
|
355
|
-
// Storage was never touched for the access token
|
|
357
|
+
// Storage was never touched for the access token.
|
|
356
358
|
expect(storage.has('oxy_access_token')).toBe(false);
|
|
357
359
|
|
|
358
360
|
// getAccessToken still resolves the active slot's token from memory.
|
|
@@ -369,7 +371,7 @@ describe('AuthManager.getAccessToken (cookieOnly in-memory fallback)', () => {
|
|
|
369
371
|
expect(token).toBeNull();
|
|
370
372
|
});
|
|
371
373
|
|
|
372
|
-
it('
|
|
374
|
+
it('ignores a retired storage token when an in-memory cookie-restored token is present', async () => {
|
|
373
375
|
const services = makeMockServices();
|
|
374
376
|
services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
|
|
375
377
|
const storage = new InMemoryStorage();
|
|
@@ -378,11 +380,11 @@ describe('AuthManager.getAccessToken (cookieOnly in-memory fallback)', () => {
|
|
|
378
380
|
// After restore the in-memory token is TOKEN_SLOT_0.
|
|
379
381
|
await manager.restoreFromCookies();
|
|
380
382
|
|
|
381
|
-
// Simulate
|
|
383
|
+
// Simulate stale token material from a retired storage path. Memory wins.
|
|
382
384
|
const STORAGE_TOKEN = buildAccessToken({ sessionId: 'sess-storage', userId: 'user-storage', exp: 9999999999 });
|
|
383
385
|
storage.setItem('oxy_access_token', STORAGE_TOKEN);
|
|
384
386
|
|
|
385
387
|
const token = await manager.getAccessToken();
|
|
386
|
-
expect(token).toBe(
|
|
388
|
+
expect(token).toBe(TOKEN_SLOT_0);
|
|
387
389
|
});
|
|
388
390
|
});
|