@oxyhq/core 5.5.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +6 -3
- package/dist/cjs/OxyServices.base.js +7 -102
- package/dist/cjs/boot/coldBootV2.js +350 -0
- package/dist/cjs/boot/deviceBootReturn.js +152 -0
- package/dist/cjs/crypto/keyManager.js +95 -0
- package/dist/cjs/i18n/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/es-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
- package/dist/cjs/index.js +47 -44
- package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
- package/dist/cjs/mixins/OxyServices.auth.js +66 -201
- package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
- package/dist/cjs/mixins/index.js +13 -17
- package/dist/cjs/session/SessionClient.js +40 -1
- package/dist/cjs/session/authStateStore.js +284 -0
- package/dist/cjs/session/refresh.js +264 -0
- package/dist/cjs/utils/accountUtils.js +1 -55
- package/dist/cjs/utils/authWebUrl.js +6 -15
- package/dist/cjs/utils/fapiAutoDetect.js +16 -64
- package/dist/cjs/utils/platform.js +19 -0
- package/dist/cjs/utils/ssoBounce.js +15 -362
- package/dist/cjs/utils/validationUtils.js +57 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +6 -3
- package/dist/esm/OxyServices.base.js +7 -102
- package/dist/esm/boot/coldBootV2.js +344 -0
- package/dist/esm/boot/deviceBootReturn.js +146 -0
- package/dist/esm/crypto/keyManager.js +95 -0
- package/dist/esm/i18n/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/es-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
- package/dist/esm/index.js +28 -18
- package/dist/esm/mixins/OxyServices.accounts.js +6 -13
- package/dist/esm/mixins/OxyServices.auth.js +66 -201
- package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
- package/dist/esm/mixins/index.js +13 -17
- package/dist/esm/session/SessionClient.js +40 -1
- package/dist/esm/session/authStateStore.js +278 -0
- package/dist/esm/session/refresh.js +257 -0
- package/dist/esm/utils/accountUtils.js +0 -53
- package/dist/esm/utils/authWebUrl.js +6 -14
- package/dist/esm/utils/fapiAutoDetect.js +16 -63
- package/dist/esm/utils/platform.js +18 -0
- package/dist/esm/utils/ssoBounce.js +14 -345
- package/dist/esm/utils/validationUtils.js +56 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +13 -0
- package/dist/types/OxyServices.base.d.ts +0 -52
- package/dist/types/OxyServices.d.ts +0 -25
- package/dist/types/boot/coldBootV2.d.ts +76 -0
- package/dist/types/boot/deviceBootReturn.d.ts +83 -0
- package/dist/types/crypto/keyManager.d.ts +21 -0
- package/dist/types/index.d.ts +15 -21
- package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
- package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
- package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
- package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -2
- package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
- package/dist/types/mixins/OxyServices.language.d.ts +0 -2
- package/dist/types/mixins/OxyServices.links.d.ts +0 -2
- package/dist/types/mixins/OxyServices.location.d.ts +0 -2
- package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
- package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
- package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
- package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
- package/dist/types/mixins/OxyServices.security.d.ts +0 -2
- package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.user.d.ts +0 -2
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
- package/dist/types/mixins/index.d.ts +6 -9
- package/dist/types/models/interfaces.d.ts +0 -67
- package/dist/types/session/SessionClient.d.ts +25 -0
- package/dist/types/session/authStateStore.d.ts +119 -0
- package/dist/types/session/refresh.d.ts +93 -0
- package/dist/types/utils/accountUtils.d.ts +0 -14
- package/dist/types/utils/authWebUrl.d.ts +6 -12
- package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
- package/dist/types/utils/platform.d.ts +14 -0
- package/dist/types/utils/ssoBounce.d.ts +14 -280
- package/dist/types/utils/validationUtils.d.ts +15 -0
- package/package.json +2 -2
- package/src/HttpService.ts +19 -3
- package/src/OxyServices.base.ts +7 -112
- package/src/OxyServices.ts +0 -38
- package/src/boot/__tests__/coldBootV2.test.ts +317 -0
- package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
- package/src/boot/coldBootV2.ts +426 -0
- package/src/boot/deviceBootReturn.ts +195 -0
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
- package/src/crypto/keyManager.ts +101 -0
- package/src/i18n/locales/en-US.json +13 -1
- package/src/i18n/locales/es-ES.json +13 -1
- package/src/index.ts +74 -65
- package/src/mixins/OxyServices.accounts.ts +6 -13
- package/src/mixins/OxyServices.auth.ts +78 -253
- package/src/mixins/OxyServices.authorizedApps.ts +75 -0
- package/src/mixins/OxyServices.deviceBoot.ts +146 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
- package/src/mixins/__tests__/accounts.test.ts +17 -44
- package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
- package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
- package/src/mixins/index.ts +16 -22
- package/src/models/interfaces.ts +0 -79
- package/src/session/SessionClient.ts +52 -1
- package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
- package/src/session/__tests__/SessionClient.state.test.ts +18 -5
- package/src/session/__tests__/authStateStore.test.ts +209 -0
- package/src/session/__tests__/refresh.test.ts +256 -0
- package/src/session/authStateStore.ts +335 -0
- package/src/session/refresh.ts +334 -0
- package/src/utils/__tests__/authWebUrl.test.ts +5 -29
- package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
- package/src/utils/__tests__/validationUtils.test.ts +30 -0
- package/src/utils/accountUtils.ts +0 -62
- package/src/utils/authWebUrl.ts +6 -15
- package/src/utils/fapiAutoDetect.ts +16 -60
- package/src/utils/platform.ts +21 -0
- package/src/utils/ssoBounce.ts +14 -393
- package/src/utils/validationUtils.ts +62 -0
- package/dist/cjs/AuthManager.js +0 -1110
- package/dist/cjs/AuthManagerTypes.js +0 -13
- package/dist/cjs/CrossDomainAuth.js +0 -206
- package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
- package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
- package/dist/cjs/mixins/OxyServices.silent.js +0 -204
- package/dist/cjs/mixins/OxyServices.sso.js +0 -208
- package/dist/cjs/utils/ssoEstablish.js +0 -110
- package/dist/cjs/utils/ssoReturn.js +0 -275
- package/dist/esm/AuthManager.js +0 -1105
- package/dist/esm/AuthManagerTypes.js +0 -12
- package/dist/esm/CrossDomainAuth.js +0 -201
- package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
- package/dist/esm/mixins/OxyServices.redirect.js +0 -92
- package/dist/esm/mixins/OxyServices.silent.js +0 -202
- package/dist/esm/mixins/OxyServices.sso.js +0 -204
- package/dist/esm/utils/ssoEstablish.js +0 -107
- package/dist/esm/utils/ssoReturn.js +0 -271
- package/dist/types/AuthManager.d.ts +0 -380
- package/dist/types/AuthManagerTypes.d.ts +0 -81
- package/dist/types/CrossDomainAuth.d.ts +0 -164
- package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
- package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
- package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
- package/dist/types/utils/ssoEstablish.d.ts +0 -85
- package/dist/types/utils/ssoReturn.d.ts +0 -156
- package/src/AuthManager.ts +0 -1269
- package/src/AuthManagerTypes.ts +0 -86
- package/src/CrossDomainAuth.ts +0 -243
- package/src/__tests__/authManager.cookiePath.test.ts +0 -390
- package/src/__tests__/authManager.security.test.ts +0 -377
- package/src/__tests__/crossDomainAuth.test.ts +0 -116
- package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
- package/src/mixins/OxyServices.fedcm.ts +0 -1026
- package/src/mixins/OxyServices.redirect.ts +0 -122
- package/src/mixins/OxyServices.silent.ts +0 -272
- package/src/mixins/OxyServices.sso.ts +0 -261
- package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
- package/src/mixins/__tests__/fedcm.test.ts +0 -667
- package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
- package/src/mixins/__tests__/silent.test.ts +0 -102
- package/src/mixins/__tests__/sso.test.ts +0 -228
- package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
- package/src/utils/__tests__/ssoBounce.test.ts +0 -219
- package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
- package/src/utils/__tests__/ssoReturn.test.ts +0 -276
- package/src/utils/ssoEstablish.ts +0 -174
- package/src/utils/ssoReturn.ts +0 -389
package/src/AuthManager.ts
DELETED
|
@@ -1,1269 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AuthManager - Centralized Authentication Manager
|
|
3
|
-
*
|
|
4
|
-
* Provides a unified authentication interface for all platforms.
|
|
5
|
-
* Handles token storage, session management, and auth state changes.
|
|
6
|
-
*
|
|
7
|
-
* @module core/AuthManager
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { OxyServices } from './OxyServices';
|
|
11
|
-
import type { SessionLoginResponse, MinimalUserData } from './models/session';
|
|
12
|
-
import type {
|
|
13
|
-
RefreshAllAccount,
|
|
14
|
-
RefreshAllAccountUser,
|
|
15
|
-
RefreshAllResponse,
|
|
16
|
-
RefreshCookieResponse,
|
|
17
|
-
User,
|
|
18
|
-
} from './models/interfaces';
|
|
19
|
-
import type {
|
|
20
|
-
AuthManagerAccount,
|
|
21
|
-
RestoreFromCookiesResult,
|
|
22
|
-
RestoreFromCookiesOptions,
|
|
23
|
-
SwitchAuthuserResult,
|
|
24
|
-
} from './AuthManagerTypes';
|
|
25
|
-
import { jwtDecode } from 'jwt-decode';
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* OxyServices already declares revokeFedCMCredential via mixin type augmentation.
|
|
29
|
-
* This alias is kept for readability in the signOut method.
|
|
30
|
-
*/
|
|
31
|
-
type OxyServicesWithFedCM = OxyServices;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Storage adapter interface for platform-agnostic storage.
|
|
35
|
-
*/
|
|
36
|
-
export interface StorageAdapter {
|
|
37
|
-
getItem: (key: string) => Promise<string | null> | string | null;
|
|
38
|
-
setItem: (key: string, value: string) => Promise<void> | void;
|
|
39
|
-
removeItem: (key: string) => Promise<void> | void;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Auth state change callback type.
|
|
44
|
-
*/
|
|
45
|
-
export type AuthStateChangeCallback = (user: MinimalUserData | null) => void;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Auth method types.
|
|
49
|
-
*/
|
|
50
|
-
export type AuthMethod = 'fedcm' | 'redirect' | 'credentials' | 'identity';
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Auth manager configuration.
|
|
54
|
-
*/
|
|
55
|
-
export interface AuthManagerConfig {
|
|
56
|
-
/** Storage adapter (localStorage, AsyncStorage, etc.) */
|
|
57
|
-
storage?: StorageAdapter;
|
|
58
|
-
/** Whether to auto-refresh tokens */
|
|
59
|
-
autoRefresh?: boolean;
|
|
60
|
-
/** Token refresh interval in milliseconds (default: 5 minutes before expiry) */
|
|
61
|
-
refreshBuffer?: number;
|
|
62
|
-
/** Enable cross-tab coordination via BroadcastChannel (default: true in browsers) */
|
|
63
|
-
crossTabSync?: boolean;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Messages sent between tabs via BroadcastChannel for token refresh coordination.
|
|
68
|
-
*
|
|
69
|
-
* Multi-account cookie-path messages keep same-origin tabs aligned while the
|
|
70
|
-
* httpOnly refresh cookies remain the authority. The handler ignores unknown
|
|
71
|
-
* types defensively so a mismatched-version sibling tab can't crash this one.
|
|
72
|
-
*
|
|
73
|
-
* Every outgoing message also carries the sender tab's `tabId` and `nonce`
|
|
74
|
-
* (see `_broadcastNonce` / `_tabId` on AuthManager). The receiver records the
|
|
75
|
-
* first (tabId, nonce) pair it sees from each tab and rejects any subsequent
|
|
76
|
-
* message from the same tabId that does not present the same nonce — a
|
|
77
|
-
* best-effort gate against forged broadcasts from a same-origin XSS payload.
|
|
78
|
-
*/
|
|
79
|
-
interface CrossTabMessage {
|
|
80
|
-
type:
|
|
81
|
-
| 'accounts_restored'
|
|
82
|
-
| 'authuser_switched'
|
|
83
|
-
| 'authuser_signed_out'
|
|
84
|
-
| 'all_signed_out';
|
|
85
|
-
sessionId?: string;
|
|
86
|
-
/** Slot index for `authuser_*` events. */
|
|
87
|
-
authuser?: number;
|
|
88
|
-
timestamp: number;
|
|
89
|
-
/** Sender-tab identifier (random hex, generated at AuthManager construction). */
|
|
90
|
-
tabId: string;
|
|
91
|
-
/** Sender-tab nonce (random hex, generated at AuthManager construction). */
|
|
92
|
-
nonce: string;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const STORAGE_KEYS = {
|
|
96
|
-
/**
|
|
97
|
-
* Persisted active `authuser` slot index for the cookie path. Stores ONLY
|
|
98
|
-
* the integer slot index (e.g. `"0"`, `"1"`), never a token or session
|
|
99
|
-
* id — that lives in the httpOnly `oxy_rt_${n}` cookie. Used so that a
|
|
100
|
-
* cold-boot `restoreFromCookies()` lands on the user's last-chosen slot
|
|
101
|
-
* instead of always defaulting to the lowest authuser.
|
|
102
|
-
*/
|
|
103
|
-
ACTIVE_AUTHUSER: 'oxy_active_authuser',
|
|
104
|
-
} as const;
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Default in-memory storage for non-browser environments.
|
|
108
|
-
*/
|
|
109
|
-
class MemoryStorage implements StorageAdapter {
|
|
110
|
-
private store = new Map<string, string>();
|
|
111
|
-
|
|
112
|
-
getItem(key: string): string | null {
|
|
113
|
-
return this.store.get(key) ?? null;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
setItem(key: string, value: string): void {
|
|
117
|
-
this.store.set(key, value);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
removeItem(key: string): void {
|
|
121
|
-
this.store.delete(key);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Browser localStorage adapter.
|
|
127
|
-
*/
|
|
128
|
-
class LocalStorageAdapter implements StorageAdapter {
|
|
129
|
-
getItem(key: string): string | null {
|
|
130
|
-
if (typeof window === 'undefined') return null;
|
|
131
|
-
try {
|
|
132
|
-
return localStorage.getItem(key);
|
|
133
|
-
} catch {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
setItem(key: string, value: string): void {
|
|
139
|
-
if (typeof window === 'undefined') return;
|
|
140
|
-
try {
|
|
141
|
-
localStorage.setItem(key, value);
|
|
142
|
-
} catch {
|
|
143
|
-
// Storage full or blocked
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
removeItem(key: string): void {
|
|
148
|
-
if (typeof window === 'undefined') return;
|
|
149
|
-
try {
|
|
150
|
-
localStorage.removeItem(key);
|
|
151
|
-
} catch {
|
|
152
|
-
// Ignore errors
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* AuthManager - Centralized authentication management.
|
|
159
|
-
*
|
|
160
|
-
* Provides a single point of control for:
|
|
161
|
-
* - Token storage and retrieval
|
|
162
|
-
* - Session management
|
|
163
|
-
* - Auth state change notifications
|
|
164
|
-
* - Multiple auth method support
|
|
165
|
-
*
|
|
166
|
-
* @example
|
|
167
|
-
* ```typescript
|
|
168
|
-
* const authManager = new AuthManager(oxyServices);
|
|
169
|
-
*
|
|
170
|
-
* // Listen for auth changes
|
|
171
|
-
* authManager.onAuthStateChange((user) => {
|
|
172
|
-
* console.log('Auth state changed:', user);
|
|
173
|
-
* });
|
|
174
|
-
*
|
|
175
|
-
* // Handle successful auth
|
|
176
|
-
* await authManager.handleAuthSuccess(session);
|
|
177
|
-
*
|
|
178
|
-
* // Sign out
|
|
179
|
-
* await authManager.signOut();
|
|
180
|
-
* ```
|
|
181
|
-
*/
|
|
182
|
-
export class AuthManager {
|
|
183
|
-
private oxyServices: OxyServices;
|
|
184
|
-
private storage: StorageAdapter;
|
|
185
|
-
private listeners: Set<AuthStateChangeCallback> = new Set();
|
|
186
|
-
private currentUser: MinimalUserData | null = null;
|
|
187
|
-
private currentAuthMethod: AuthMethod | null = null;
|
|
188
|
-
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
189
|
-
private refreshPromise: Promise<boolean> | null = null;
|
|
190
|
-
private config: Required<Omit<AuthManagerConfig, 'crossTabSync'>> & {
|
|
191
|
-
crossTabSync: boolean;
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
/** Tracks the access token this instance last knew about, for cross-tab adoption. */
|
|
195
|
-
private _lastKnownAccessToken: string | null = null;
|
|
196
|
-
|
|
197
|
-
/** BroadcastChannel for coordinating token refreshes across browser tabs. */
|
|
198
|
-
private _broadcastChannel: BroadcastChannel | null = null;
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Identifier for this AuthManager instance (≈ "this tab"). Random hex
|
|
202
|
-
* generated at construction; advertised in every outgoing broadcast and
|
|
203
|
-
* used as the lookup key in `_knownPeerNonces`.
|
|
204
|
-
*/
|
|
205
|
-
private readonly _tabId: string = AuthManager._randomHex(16);
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Per-tab nonce, advertised in every outgoing broadcast. Receivers record
|
|
209
|
-
* the first (tabId, nonce) pair they see from a given peer; subsequent
|
|
210
|
-
* messages from the same tabId MUST carry the same nonce or they're
|
|
211
|
-
* ignored.
|
|
212
|
-
*
|
|
213
|
-
* Threat model: a same-origin XSS payload can post to the channel but can
|
|
214
|
-
* NOT read this instance's private `_broadcastNonce` field (it lives in
|
|
215
|
-
* closure, not on `window`). Forged broadcasts from XSS therefore can't
|
|
216
|
-
* impersonate this tab. A new attacker-controlled tabId trips the
|
|
217
|
-
* "first message from a new peer" branch, which is by definition trusted
|
|
218
|
-
* — so the gate raises the bar but is not a complete defence (a perfect
|
|
219
|
-
* mitigation would require message signing with a server-issued key).
|
|
220
|
-
*/
|
|
221
|
-
private readonly _broadcastNonce: string = AuthManager._randomHex(16);
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Bounded LRU of `(tabId → nonce)` pairs seen on inbound broadcasts. First
|
|
225
|
-
* sighting of a new tabId records its nonce; later messages from that
|
|
226
|
-
* tabId are rejected if the nonce doesn't match.
|
|
227
|
-
*/
|
|
228
|
-
private readonly _knownPeerNonces: Map<string, string> = new Map();
|
|
229
|
-
private static readonly _MAX_KNOWN_PEERS = 32;
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* In-flight `switchAuthuser` promise. Deduplicates concurrent calls so two
|
|
233
|
-
* near-simultaneous switches don't both fire refresh requests and rotate
|
|
234
|
-
* the slot twice. Mirrors the `refreshPromise` pattern used by
|
|
235
|
-
* `refreshToken`.
|
|
236
|
-
*/
|
|
237
|
-
private _switchPromise: Promise<SwitchAuthuserResult> | null = null;
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Last `restoreFromCookies()` completion timestamp, keyed by the
|
|
241
|
-
* AuthManager's active authuser at the time of completion. Used to gate
|
|
242
|
-
* cross-tab cascade: a flurry of BroadcastChannel events from sibling
|
|
243
|
-
* tabs can otherwise trigger N back-to-back snapshots and rotate every
|
|
244
|
-
* slot's access token N times.
|
|
245
|
-
*/
|
|
246
|
-
private readonly _lastRestoreAt: Map<number, number> = new Map();
|
|
247
|
-
private static readonly _RESTORE_DEBOUNCE_MS = 2000;
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* In-memory registry of every device-local account the AuthManager knows
|
|
251
|
-
* about, keyed by `authuser` slot index. Populated by:
|
|
252
|
-
* - `restoreFromCookies()` (cold boot)
|
|
253
|
-
* - `switchAuthuser()` (per-account rotation)
|
|
254
|
-
* - `handleAuthSuccess()` (fresh login when the server response carries
|
|
255
|
-
* an `authuser` field)
|
|
256
|
-
* Access tokens live ONLY here in the cookie path — they are never
|
|
257
|
-
* persisted to localStorage.
|
|
258
|
-
*/
|
|
259
|
-
private accounts: Map<number, AuthManagerAccount> = new Map();
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Currently-active `authuser` slot in the cookie path. `null` means either
|
|
263
|
-
* the cookie path hasn't been initialised yet, or no slots are signed in.
|
|
264
|
-
*/
|
|
265
|
-
private activeAuthuser: number | null = null;
|
|
266
|
-
|
|
267
|
-
constructor(oxyServices: OxyServices, config: AuthManagerConfig = {}) {
|
|
268
|
-
this.oxyServices = oxyServices;
|
|
269
|
-
const crossTabSync = config.crossTabSync ?? (typeof BroadcastChannel !== 'undefined');
|
|
270
|
-
this.config = {
|
|
271
|
-
storage: config.storage ?? this.getDefaultStorage(),
|
|
272
|
-
autoRefresh: config.autoRefresh ?? true,
|
|
273
|
-
refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
|
|
274
|
-
crossTabSync,
|
|
275
|
-
};
|
|
276
|
-
this.storage = this.config.storage;
|
|
277
|
-
|
|
278
|
-
this.oxyServices.httpService.setAuthRefreshHandler(async () => {
|
|
279
|
-
const refreshed = await this.refreshToken();
|
|
280
|
-
return refreshed ? this._lastKnownAccessToken : null;
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
// Setup cross-tab coordination in browser environments
|
|
284
|
-
if (this.config.crossTabSync) {
|
|
285
|
-
this._initBroadcastChannel();
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Initialize BroadcastChannel for cross-tab token refresh coordination.
|
|
291
|
-
* Only called in browser environments where BroadcastChannel is available.
|
|
292
|
-
*/
|
|
293
|
-
private _initBroadcastChannel(): void {
|
|
294
|
-
if (typeof BroadcastChannel === 'undefined') return;
|
|
295
|
-
|
|
296
|
-
try {
|
|
297
|
-
this._broadcastChannel = new BroadcastChannel('oxy_auth_sync');
|
|
298
|
-
this._broadcastChannel.onmessage = (event: MessageEvent<CrossTabMessage>) => {
|
|
299
|
-
this._handleCrossTabMessage(event.data);
|
|
300
|
-
};
|
|
301
|
-
} catch {
|
|
302
|
-
// BroadcastChannel not supported or blocked (e.g., opaque origins)
|
|
303
|
-
this._broadcastChannel = null;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Handle messages from other tabs about token refresh activity.
|
|
309
|
-
*/
|
|
310
|
-
private async _handleCrossTabMessage(message: CrossTabMessage): Promise<void> {
|
|
311
|
-
if (!message || !message.type) return;
|
|
312
|
-
if (!this._acceptBroadcast(message)) return;
|
|
313
|
-
|
|
314
|
-
switch (message.type) {
|
|
315
|
-
case 'accounts_restored':
|
|
316
|
-
case 'authuser_switched':
|
|
317
|
-
case 'authuser_signed_out': {
|
|
318
|
-
// Another tab restored/switched/dropped a slot. The authoritative
|
|
319
|
-
// state lives in the httpOnly cookies which we can't read from JS,
|
|
320
|
-
// so the cleanest reaction is to re-run `restoreFromCookies()` on
|
|
321
|
-
// a microtask and re-sync our in-memory registry. We swallow
|
|
322
|
-
// failures: a transient network error must not bring down a tab
|
|
323
|
-
// that already had a valid session.
|
|
324
|
-
//
|
|
325
|
-
// The restoreFromCookies() body owns the per-slot debounce so a
|
|
326
|
-
// burst of N broadcasts only costs one /auth/refresh-all rotation
|
|
327
|
-
// (instead of N back-to-back rotations of every cookie slot).
|
|
328
|
-
Promise.resolve().then(() => {
|
|
329
|
-
this.restoreFromCookies().catch(() => {
|
|
330
|
-
// Best-effort; existing accounts (if any) remain intact.
|
|
331
|
-
});
|
|
332
|
-
});
|
|
333
|
-
break;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
case 'all_signed_out': {
|
|
337
|
-
// Wipe the cookie-path registry after another tab signed every slot out.
|
|
338
|
-
if (this.refreshTimer) {
|
|
339
|
-
clearTimeout(this.refreshTimer);
|
|
340
|
-
this.refreshTimer = null;
|
|
341
|
-
}
|
|
342
|
-
this.refreshPromise = null;
|
|
343
|
-
this.accounts.clear();
|
|
344
|
-
this.activeAuthuser = null;
|
|
345
|
-
this._lastKnownAccessToken = null;
|
|
346
|
-
this.oxyServices.httpService.setTokens('');
|
|
347
|
-
this.currentUser = null;
|
|
348
|
-
this.currentAuthMethod = null;
|
|
349
|
-
this.notifyListeners();
|
|
350
|
-
break;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Broadcast a message to other tabs. Always stamps this tab's `tabId` and
|
|
358
|
-
* `nonce` onto the message so receivers can run the cross-tab nonce gate.
|
|
359
|
-
*/
|
|
360
|
-
private _broadcast(message: Omit<CrossTabMessage, 'tabId' | 'nonce'>): void {
|
|
361
|
-
const stamped: CrossTabMessage = {
|
|
362
|
-
...message,
|
|
363
|
-
tabId: this._tabId,
|
|
364
|
-
nonce: this._broadcastNonce,
|
|
365
|
-
};
|
|
366
|
-
try {
|
|
367
|
-
this._broadcastChannel?.postMessage(stamped);
|
|
368
|
-
} catch {
|
|
369
|
-
// Channel closed or unavailable
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* Generate `bytes` bytes of cryptographic randomness encoded as lowercase
|
|
375
|
-
* hex. Prefers Web Crypto's `getRandomValues` when available (browser /
|
|
376
|
-
* modern Node); falls back to `Math.random` ONLY in environments without
|
|
377
|
-
* Web Crypto (the resulting nonce is still unguessable to a same-origin
|
|
378
|
-
* XSS payload — the goal is unforgeability across tabs, not cryptographic
|
|
379
|
-
* secrecy across the network).
|
|
380
|
-
*/
|
|
381
|
-
private static _randomHex(bytes: number): string {
|
|
382
|
-
const buffer = new Uint8Array(bytes);
|
|
383
|
-
const gcrypto: Crypto | undefined =
|
|
384
|
-
typeof globalThis !== 'undefined'
|
|
385
|
-
? (globalThis as { crypto?: Crypto }).crypto
|
|
386
|
-
: undefined;
|
|
387
|
-
if (gcrypto && typeof gcrypto.getRandomValues === 'function') {
|
|
388
|
-
gcrypto.getRandomValues(buffer);
|
|
389
|
-
} else {
|
|
390
|
-
for (let i = 0; i < buffer.length; i++) {
|
|
391
|
-
buffer[i] = Math.floor(Math.random() * 256);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
let hex = '';
|
|
395
|
-
for (const byte of buffer) {
|
|
396
|
-
hex += byte.toString(16).padStart(2, '0');
|
|
397
|
-
}
|
|
398
|
-
return hex;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Validate an inbound broadcast against the cross-tab nonce gate.
|
|
403
|
-
*
|
|
404
|
-
* Returns `true` when the message should be honoured, `false` when it
|
|
405
|
-
* MUST be ignored:
|
|
406
|
-
* - Message is missing `tabId` or `nonce` → ignore (forged or
|
|
407
|
-
* mismatched-version sibling tab).
|
|
408
|
-
* - First sighting of `tabId` → record the nonce and honour the message
|
|
409
|
-
* (trust-on-first-use, the best we can do without a shared secret).
|
|
410
|
-
* - Subsequent message from the same `tabId` with the SAME nonce →
|
|
411
|
-
* honour.
|
|
412
|
-
* - Subsequent message from the same `tabId` with a DIFFERENT nonce →
|
|
413
|
-
* ignore (the canonical "forged broadcast" case — a same-origin XSS
|
|
414
|
-
* payload can't read the real tab's `_broadcastNonce`).
|
|
415
|
-
*
|
|
416
|
-
* Echoes of this tab's own broadcasts (same `tabId`) are also dropped so
|
|
417
|
-
* we don't react to our own messages.
|
|
418
|
-
*/
|
|
419
|
-
private _acceptBroadcast(message: CrossTabMessage | null | undefined): boolean {
|
|
420
|
-
if (!message || typeof message.tabId !== 'string' || typeof message.nonce !== 'string') {
|
|
421
|
-
return false;
|
|
422
|
-
}
|
|
423
|
-
if (message.tabId === this._tabId) {
|
|
424
|
-
// Same-tab echo. Some BroadcastChannel implementations deliver our own
|
|
425
|
-
// posts back to us; never act on those.
|
|
426
|
-
return false;
|
|
427
|
-
}
|
|
428
|
-
const seen = this._knownPeerNonces.get(message.tabId);
|
|
429
|
-
if (seen === undefined) {
|
|
430
|
-
// Trust-on-first-use. Bound the map to avoid unbounded growth from a
|
|
431
|
-
// tab-id sprayer.
|
|
432
|
-
if (this._knownPeerNonces.size >= AuthManager._MAX_KNOWN_PEERS) {
|
|
433
|
-
const oldest = this._knownPeerNonces.keys().next().value;
|
|
434
|
-
if (oldest !== undefined) {
|
|
435
|
-
this._knownPeerNonces.delete(oldest);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
this._knownPeerNonces.set(message.tabId, message.nonce);
|
|
439
|
-
return true;
|
|
440
|
-
}
|
|
441
|
-
return seen === message.nonce;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Get default storage based on environment.
|
|
446
|
-
*/
|
|
447
|
-
private getDefaultStorage(): StorageAdapter {
|
|
448
|
-
try {
|
|
449
|
-
if (typeof window !== 'undefined' && window.localStorage) {
|
|
450
|
-
return new LocalStorageAdapter();
|
|
451
|
-
}
|
|
452
|
-
} catch {
|
|
453
|
-
// Accessing window.localStorage can throw in opaque-origin/sandboxed
|
|
454
|
-
// browser contexts or when storage is disabled. Fall back to memory so
|
|
455
|
-
// AuthManager construction remains safe during provider render.
|
|
456
|
-
}
|
|
457
|
-
return new MemoryStorage();
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* Subscribe to auth state changes.
|
|
462
|
-
*
|
|
463
|
-
* @param callback - Function called when auth state changes
|
|
464
|
-
* @returns Unsubscribe function
|
|
465
|
-
*/
|
|
466
|
-
onAuthStateChange(callback: AuthStateChangeCallback): () => void {
|
|
467
|
-
this.listeners.add(callback);
|
|
468
|
-
// Call immediately with current state
|
|
469
|
-
callback(this.currentUser);
|
|
470
|
-
return () => {
|
|
471
|
-
this.listeners.delete(callback);
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Notify all listeners of auth state change.
|
|
477
|
-
*/
|
|
478
|
-
private notifyListeners(): void {
|
|
479
|
-
for (const listener of this.listeners) {
|
|
480
|
-
try {
|
|
481
|
-
listener(this.currentUser);
|
|
482
|
-
} catch {
|
|
483
|
-
// Ignore listener errors
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Handle successful authentication.
|
|
490
|
-
*
|
|
491
|
-
* @param session - Session response from auth
|
|
492
|
-
* @param method - Auth method used
|
|
493
|
-
*/
|
|
494
|
-
async handleAuthSuccess(
|
|
495
|
-
session: SessionLoginResponse,
|
|
496
|
-
method: AuthMethod = 'credentials'
|
|
497
|
-
): Promise<void> {
|
|
498
|
-
// Access tokens are memory-only. Fresh login responses plant the token on
|
|
499
|
-
// the HTTP client and the AuthManager registry, but never write it to JS
|
|
500
|
-
// storage. Durable web refresh lives in the httpOnly cookie set by the API.
|
|
501
|
-
if (session.accessToken) {
|
|
502
|
-
this._lastKnownAccessToken = session.accessToken;
|
|
503
|
-
this.oxyServices.httpService.setTokens(session.accessToken);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
if (session.user && typeof session.user.id === 'string' && session.user.id.length > 0) {
|
|
507
|
-
this.currentUser = session.user;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
this.currentAuthMethod = method;
|
|
511
|
-
|
|
512
|
-
// Register this primary session in the device's first-party multi-account
|
|
513
|
-
// refresh-cookie set (web only). Every web primary commit funnels through
|
|
514
|
-
// here (FedCM, central `/sso` return, credentials), but only a same-apex
|
|
515
|
-
// `/fedcm/exchange` plants an `oxy_rt_<authuser>` slot as a side effect — the
|
|
516
|
-
// cross-origin/credential-less restores (`/sso/exchange`, the IdP
|
|
517
|
-
// `/auth/silent` postMessage) cannot set an `api.oxy.so` cookie. WITHOUT this
|
|
518
|
-
// call those primaries never join the device set, so `refresh-all` returns no
|
|
519
|
-
// accounts and account-switch persistence has no foundation. The shared
|
|
520
|
-
// `POST /auth/session` primitive plants/reuses this user's slot where the
|
|
521
|
-
// cookies ARE visible, re-plants the rotated access token, and returns the
|
|
522
|
-
// AUTHORITATIVE `authuser` slot (the one actually written) — preferred over the
|
|
523
|
-
// JWT-decoded guess, which may not correspond to a real cookie. No-op on native
|
|
524
|
-
// (returns `null`); best-effort on transient failure.
|
|
525
|
-
const establishedAuthuser = await this.oxyServices.establishDeviceRefreshSlot();
|
|
526
|
-
// The slot mint rotated the access token; pick it up so the registry and the
|
|
527
|
-
// refresh authority track the cookie just written. Falls back to the original
|
|
528
|
-
// session token when the slot was a no-op (native) or failed.
|
|
529
|
-
const rotatedToken =
|
|
530
|
-
(establishedAuthuser !== null ? this.oxyServices.getAccessToken() : null) ??
|
|
531
|
-
session.accessToken;
|
|
532
|
-
if (rotatedToken) {
|
|
533
|
-
this._lastKnownAccessToken = rotatedToken;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
const decodedAuthuser = rotatedToken
|
|
537
|
-
? AuthManager.decodeAuthuserFromAccessToken(rotatedToken)
|
|
538
|
-
: null;
|
|
539
|
-
const authuser = establishedAuthuser ?? decodedAuthuser ?? 0;
|
|
540
|
-
if (rotatedToken && session.sessionId) {
|
|
541
|
-
this.accounts.set(authuser, {
|
|
542
|
-
authuser,
|
|
543
|
-
sessionId: session.sessionId,
|
|
544
|
-
user: {
|
|
545
|
-
id: session.user.id,
|
|
546
|
-
username: session.user.username,
|
|
547
|
-
name: session.user.name,
|
|
548
|
-
avatar: session.user.avatar ?? null,
|
|
549
|
-
},
|
|
550
|
-
accessToken: rotatedToken,
|
|
551
|
-
expiresAt: session.expiresAt,
|
|
552
|
-
});
|
|
553
|
-
this.activeAuthuser = authuser;
|
|
554
|
-
await this.writeActiveAuthuser(authuser);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// Setup auto-refresh if enabled
|
|
558
|
-
if (this.config.autoRefresh && session.expiresAt) {
|
|
559
|
-
this.setupCookieRefresh(session.expiresAt, authuser);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// Notify listeners
|
|
563
|
-
this.notifyListeners();
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Refresh the access token. Deduplicates concurrent calls so only one
|
|
568
|
-
* refresh request is in-flight at a time. The only refresh authority is the
|
|
569
|
-
* active httpOnly refresh-cookie slot; this method never reads access tokens
|
|
570
|
-
* from storage.
|
|
571
|
-
*/
|
|
572
|
-
async refreshToken(): Promise<boolean> {
|
|
573
|
-
// If a refresh is already in-flight, return the same promise
|
|
574
|
-
if (this.refreshPromise) {
|
|
575
|
-
return this.refreshPromise;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
this.refreshPromise = this._doRefreshToken();
|
|
579
|
-
try {
|
|
580
|
-
return await this.refreshPromise;
|
|
581
|
-
} finally {
|
|
582
|
-
this.refreshPromise = null;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
private async _doRefreshToken(): Promise<boolean> {
|
|
587
|
-
try {
|
|
588
|
-
if (this.activeAuthuser !== null) {
|
|
589
|
-
await this.switchAuthuser(this.activeAuthuser);
|
|
590
|
-
return true;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const restored = await this.restoreFromCookies();
|
|
594
|
-
return restored.accounts.length > 0;
|
|
595
|
-
} catch {
|
|
596
|
-
await this.clearSession();
|
|
597
|
-
this.currentUser = null;
|
|
598
|
-
this.accounts.clear();
|
|
599
|
-
this.activeAuthuser = null;
|
|
600
|
-
this._lastKnownAccessToken = null;
|
|
601
|
-
this.oxyServices.httpService.setTokens('');
|
|
602
|
-
this.notifyListeners();
|
|
603
|
-
return false;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* Sign out and clear all auth data.
|
|
609
|
-
*/
|
|
610
|
-
async signOut(): Promise<void> {
|
|
611
|
-
// Clear refresh timer and cancel any in-flight refresh
|
|
612
|
-
if (this.refreshTimer) {
|
|
613
|
-
clearTimeout(this.refreshTimer);
|
|
614
|
-
this.refreshTimer = null;
|
|
615
|
-
}
|
|
616
|
-
this.refreshPromise = null;
|
|
617
|
-
|
|
618
|
-
// Invalidate current cookie-backed sessions on the server (best-effort)
|
|
619
|
-
try {
|
|
620
|
-
await this.signOutAllViaCookies();
|
|
621
|
-
} catch {
|
|
622
|
-
// Best-effort: don't block local signout on network failure
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Revoke FedCM credential if supported
|
|
626
|
-
try {
|
|
627
|
-
const services = this.oxyServices as OxyServicesWithFedCM;
|
|
628
|
-
if (services.revokeFedCMCredential) {
|
|
629
|
-
await services.revokeFedCMCredential();
|
|
630
|
-
}
|
|
631
|
-
} catch {
|
|
632
|
-
// Ignore FedCM errors
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// Clear HTTP client tokens
|
|
636
|
-
this.oxyServices.httpService.setTokens('');
|
|
637
|
-
this._lastKnownAccessToken = null;
|
|
638
|
-
|
|
639
|
-
// Clear storage
|
|
640
|
-
await this.clearSession();
|
|
641
|
-
|
|
642
|
-
// Update state and notify
|
|
643
|
-
this.currentUser = null;
|
|
644
|
-
this.notifyListeners();
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/**
|
|
648
|
-
* Clear local cookie-path state. The only persisted AuthManager value is the
|
|
649
|
-
* active numeric slot; tokens and user objects are intentionally memory-only.
|
|
650
|
-
*/
|
|
651
|
-
private async clearSession(): Promise<void> {
|
|
652
|
-
await this.clearActiveAuthuser();
|
|
653
|
-
this.currentAuthMethod = null;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/**
|
|
657
|
-
* Get current user.
|
|
658
|
-
*/
|
|
659
|
-
getCurrentUser(): MinimalUserData | null {
|
|
660
|
-
return this.currentUser;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* Check if user is authenticated.
|
|
665
|
-
*/
|
|
666
|
-
isAuthenticated(): boolean {
|
|
667
|
-
return this.currentUser !== null;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* Get a valid access token, refreshing automatically if expired or expiring
|
|
672
|
-
* soon. The token is read from memory only.
|
|
673
|
-
*/
|
|
674
|
-
async getAccessToken(): Promise<string | null> {
|
|
675
|
-
const token = this._lastKnownAccessToken;
|
|
676
|
-
if (!token) return null;
|
|
677
|
-
|
|
678
|
-
try {
|
|
679
|
-
const decoded = jwtDecode<{ exp?: number }>(token);
|
|
680
|
-
if (decoded.exp) {
|
|
681
|
-
const now = Math.floor(Date.now() / 1000);
|
|
682
|
-
const buffer = 60; // refresh 60 seconds before expiry
|
|
683
|
-
if (decoded.exp - now < buffer) {
|
|
684
|
-
const refreshed = await this.refreshToken();
|
|
685
|
-
if (refreshed) {
|
|
686
|
-
return this._lastKnownAccessToken;
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
} catch {
|
|
691
|
-
// Decode failed — return token as-is, let the server decide
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
return token;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/**
|
|
698
|
-
* Get the auth method used for current session.
|
|
699
|
-
*/
|
|
700
|
-
async getAuthMethod(): Promise<AuthMethod | null> {
|
|
701
|
-
return this.currentAuthMethod;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/**
|
|
705
|
-
* Initialize auth state on app startup.
|
|
706
|
-
*
|
|
707
|
-
* Only the cookie path is authoritative. `restoreFromCookies()` refreshes
|
|
708
|
-
* the httpOnly `oxy_rt_${authuser}` slots through `/auth/refresh-all`,
|
|
709
|
-
* plants the active access token in memory, and returns the active user.
|
|
710
|
-
* No access token, refresh token, or session JSON is read from localStorage.
|
|
711
|
-
*
|
|
712
|
-
* Returns the active user on success, or `null` when no cookie-backed
|
|
713
|
-
* account was restored.
|
|
714
|
-
*/
|
|
715
|
-
async initialize(options: RestoreFromCookiesOptions = {}): Promise<MinimalUserData | null> {
|
|
716
|
-
const cookieResult = await this.restoreFromCookies(options);
|
|
717
|
-
if (cookieResult.accounts.length > 0) {
|
|
718
|
-
return this.currentUser;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
return null;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// -------------------------------------------------------------------------
|
|
725
|
-
// Multi-account cookie path (Google-style multi-sign-in).
|
|
726
|
-
// -------------------------------------------------------------------------
|
|
727
|
-
// The cookie path is web-only. It never touches the retired
|
|
728
|
-
// `oxy_access_token` / `oxy_refresh_token` /
|
|
729
|
-
// `oxy_session` localStorage keys, because the refresh token lives in the
|
|
730
|
-
// httpOnly `oxy_rt_${authuser}` cookies and access tokens live in
|
|
731
|
-
// `this.accounts` (in-memory only). The only localStorage key the cookie
|
|
732
|
-
// path writes is `STORAGE_KEYS.ACTIVE_AUTHUSER` — a small integer that is
|
|
733
|
-
// explicitly NOT a secret.
|
|
734
|
-
//
|
|
735
|
-
// `initialize()` only uses the cookie path.
|
|
736
|
-
// -------------------------------------------------------------------------
|
|
737
|
-
|
|
738
|
-
/**
|
|
739
|
-
* Read the persisted active `authuser` slot index. Returns `null` when
|
|
740
|
-
* none is persisted, the value is corrupt, or the storage adapter has no
|
|
741
|
-
* record. Storage failures are non-fatal: the cookie path falls back to
|
|
742
|
-
* "lowest authuser" deterministic selection.
|
|
743
|
-
*/
|
|
744
|
-
private async readActiveAuthuser(): Promise<number | null> {
|
|
745
|
-
try {
|
|
746
|
-
const raw = await this.storage.getItem(STORAGE_KEYS.ACTIVE_AUTHUSER);
|
|
747
|
-
if (raw === null || raw === undefined) return null;
|
|
748
|
-
const parsed = Number.parseInt(raw, 10);
|
|
749
|
-
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
|
750
|
-
return parsed;
|
|
751
|
-
} catch {
|
|
752
|
-
return null;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
/**
|
|
757
|
-
* Persist the active `authuser` slot index. No-ops on storage failure
|
|
758
|
-
* (e.g. Safari private mode, native SecureStore unavailable) — this is
|
|
759
|
-
* best-effort UX persistence, not authoritative state.
|
|
760
|
-
*/
|
|
761
|
-
private async writeActiveAuthuser(authuser: number): Promise<void> {
|
|
762
|
-
if (!Number.isFinite(authuser) || authuser < 0) return;
|
|
763
|
-
try {
|
|
764
|
-
await this.storage.setItem(STORAGE_KEYS.ACTIVE_AUTHUSER, String(authuser));
|
|
765
|
-
} catch {
|
|
766
|
-
// Best-effort.
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
/**
|
|
771
|
-
* Clear the persisted active `authuser` so the next cold boot starts from
|
|
772
|
-
* a clean slate (used on full sign-out).
|
|
773
|
-
*/
|
|
774
|
-
private async clearActiveAuthuser(): Promise<void> {
|
|
775
|
-
try {
|
|
776
|
-
await this.storage.removeItem(STORAGE_KEYS.ACTIVE_AUTHUSER);
|
|
777
|
-
} catch {
|
|
778
|
-
// Best-effort.
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* Build a `MinimalUserData` from a `RefreshAllAccount`. Returns `null` when
|
|
784
|
-
* the wire entry has no user shape; the AuthManager's caller is expected to
|
|
785
|
-
* hydrate via `/users/me` in that case.
|
|
786
|
-
*/
|
|
787
|
-
private static toMinimalUser(account: RefreshAllAccount): MinimalUserData | null {
|
|
788
|
-
if (!account.user) return null;
|
|
789
|
-
return {
|
|
790
|
-
id: account.user.id,
|
|
791
|
-
username: account.user.username,
|
|
792
|
-
name: account.user.name,
|
|
793
|
-
avatar: account.user.avatar ?? undefined,
|
|
794
|
-
};
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
/**
|
|
798
|
-
* Hydrate the user shape for a slot whose AuthManagerAccount currently has
|
|
799
|
-
* `user: null` (for example, a switch onto a previously unknown slot). Calls
|
|
800
|
-
* `/users/me` with the slot's freshly-planted access
|
|
801
|
-
* token already on the HTTP client; merges the result back into the
|
|
802
|
-
* registry entry. Network failures are non-fatal — the slot remains with
|
|
803
|
-
* `user: null` and the UI is expected to render the public-key fallback
|
|
804
|
-
* handle until a later restore picks the real user shape up.
|
|
805
|
-
*/
|
|
806
|
-
private async _hydrateUnknownUser(authuser: number): Promise<void> {
|
|
807
|
-
const oxy = this.oxyServices as OxyServices & {
|
|
808
|
-
getCurrentUser?: () => Promise<User>;
|
|
809
|
-
};
|
|
810
|
-
if (typeof oxy.getCurrentUser !== 'function') return;
|
|
811
|
-
|
|
812
|
-
let me: User;
|
|
813
|
-
try {
|
|
814
|
-
me = await oxy.getCurrentUser();
|
|
815
|
-
} catch {
|
|
816
|
-
// Best-effort: keep `user: null` and let the UI fall back to the
|
|
817
|
-
// public-key handle.
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
const existing = this.accounts.get(authuser);
|
|
822
|
-
if (!existing) return;
|
|
823
|
-
|
|
824
|
-
const hydrated: RefreshAllAccountUser = {
|
|
825
|
-
id: me.id,
|
|
826
|
-
username: me.username,
|
|
827
|
-
// `User.name` and `RefreshAllAccountUser.name` are the same canonical
|
|
828
|
-
// structured `UserNameResponse` shape, so forward it verbatim.
|
|
829
|
-
name: me.name,
|
|
830
|
-
avatar: me.avatar ?? null,
|
|
831
|
-
email: me.email,
|
|
832
|
-
color: me.color ?? null,
|
|
833
|
-
};
|
|
834
|
-
this.accounts.set(authuser, { ...existing, user: hydrated });
|
|
835
|
-
|
|
836
|
-
// Mirror onto `currentUser` if this is the active slot.
|
|
837
|
-
if (this.activeAuthuser === authuser) {
|
|
838
|
-
this.currentUser = {
|
|
839
|
-
id: hydrated.id,
|
|
840
|
-
username: hydrated.username,
|
|
841
|
-
name: hydrated.name,
|
|
842
|
-
avatar: hydrated.avatar ?? undefined,
|
|
843
|
-
};
|
|
844
|
-
this.notifyListeners();
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* Snapshot of the registered cookie-path accounts, sorted by `authuser`
|
|
850
|
-
* ascending (canonical order). Mutating the returned array does not
|
|
851
|
-
* affect AuthManager state.
|
|
852
|
-
*/
|
|
853
|
-
getAccounts(): AuthManagerAccount[] {
|
|
854
|
-
return Array.from(this.accounts.values()).sort((a, b) => a.authuser - b.authuser);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
/**
|
|
858
|
-
* The slot index that is currently active in the cookie path, or `null`
|
|
859
|
-
* if the cookie path hasn't been initialised or no slots are signed in.
|
|
860
|
-
*/
|
|
861
|
-
getActiveAuthuser(): number | null {
|
|
862
|
-
return this.activeAuthuser;
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
/**
|
|
866
|
-
* Convenience: the AuthManagerAccount currently flagged active.
|
|
867
|
-
*/
|
|
868
|
-
getActiveAccount(): AuthManagerAccount | null {
|
|
869
|
-
if (this.activeAuthuser === null) return null;
|
|
870
|
-
return this.accounts.get(this.activeAuthuser) ?? null;
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
/**
|
|
874
|
-
* Restore every device-local account from the httpOnly refresh cookies.
|
|
875
|
-
*
|
|
876
|
-
* Calls `oxyServices.refreshAllSessions()` (`POST /auth/refresh-all` with
|
|
877
|
-
* `credentials: 'include'`). The server rotates every presented
|
|
878
|
-
* `oxy_rt_${authuser}` cookie in parallel and returns one entry per
|
|
879
|
-
* valid slot.
|
|
880
|
-
*
|
|
881
|
-
* Plants the active account's access token on the shared HTTP client;
|
|
882
|
-
* sibling slots' tokens stay in the in-memory registry so a later
|
|
883
|
-
* `switchAuthuser()` can hot-swap them without a network round-trip.
|
|
884
|
-
*
|
|
885
|
-
* The persisted `oxy_active_authuser` slot wins when it matches a
|
|
886
|
-
* returned account; otherwise the lowest returned `authuser` is chosen
|
|
887
|
-
* deterministically.
|
|
888
|
-
*
|
|
889
|
-
* Returns `{ accounts: [], activeAuthuser: null }` on any failure or
|
|
890
|
-
* empty snapshot — callers treat that as "no signed-in accounts" and
|
|
891
|
-
* proceed unauthenticated. State is NOT cleared on failure; existing
|
|
892
|
-
* accounts (if any) remain intact.
|
|
893
|
-
*/
|
|
894
|
-
async restoreFromCookies(options: RestoreFromCookiesOptions = {}): Promise<RestoreFromCookiesResult> {
|
|
895
|
-
// Cross-tab cascade debounce. If we restored within the last
|
|
896
|
-
// _RESTORE_DEBOUNCE_MS for the currently-active slot, skip the network
|
|
897
|
-
// round-trip and return the cached registry verbatim. A burst of N
|
|
898
|
-
// BroadcastChannel events from sibling tabs therefore costs at most one
|
|
899
|
-
// /auth/refresh-all rotation. Cold-boot calls (activeAuthuser still
|
|
900
|
-
// null) always run because the cache hasn't been seeded yet.
|
|
901
|
-
if (this.activeAuthuser !== null) {
|
|
902
|
-
const last = this._lastRestoreAt.get(this.activeAuthuser);
|
|
903
|
-
if (last !== undefined && Date.now() - last < AuthManager._RESTORE_DEBOUNCE_MS) {
|
|
904
|
-
return {
|
|
905
|
-
accounts: this.getAccounts(),
|
|
906
|
-
activeAuthuser: this.activeAuthuser,
|
|
907
|
-
};
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
let snapshot: RefreshAllResponse;
|
|
912
|
-
try {
|
|
913
|
-
// Forward the optional cold-boot fail-fast timeout. Undefined (the warm
|
|
914
|
-
// cross-tab cascade default) preserves the wait-indefinitely behaviour.
|
|
915
|
-
snapshot = await this.oxyServices.refreshAllSessions({ timeout: options.timeout });
|
|
916
|
-
} catch {
|
|
917
|
-
return { accounts: [], activeAuthuser: null };
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
if (snapshot.accounts.length === 0) {
|
|
921
|
-
return { accounts: [], activeAuthuser: null };
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
// Replace the registry wholesale: the server's snapshot is authoritative.
|
|
925
|
-
this.accounts.clear();
|
|
926
|
-
for (const account of snapshot.accounts) {
|
|
927
|
-
this.accounts.set(account.authuser, {
|
|
928
|
-
authuser: account.authuser,
|
|
929
|
-
sessionId: account.sessionId,
|
|
930
|
-
user: account.user,
|
|
931
|
-
accessToken: account.accessToken,
|
|
932
|
-
expiresAt: account.expiresAt,
|
|
933
|
-
});
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
// Pick the active slot: persisted `oxy_active_authuser` wins if it
|
|
937
|
-
// matches a returned account; otherwise the lowest returned authuser
|
|
938
|
-
// (the snapshot is already sorted ascending, so accounts[0] is the
|
|
939
|
-
// lowest).
|
|
940
|
-
const persisted = await this.readActiveAuthuser();
|
|
941
|
-
const active = (persisted !== null && this.accounts.has(persisted))
|
|
942
|
-
? persisted
|
|
943
|
-
: snapshot.accounts[0].authuser;
|
|
944
|
-
|
|
945
|
-
this.activeAuthuser = active;
|
|
946
|
-
const activeAccount = this.accounts.get(active);
|
|
947
|
-
const slotsNeedingHydration: number[] = [];
|
|
948
|
-
if (activeAccount) {
|
|
949
|
-
this._lastKnownAccessToken = activeAccount.accessToken;
|
|
950
|
-
this.oxyServices.httpService.setTokens(activeAccount.accessToken);
|
|
951
|
-
this.currentUser = AuthManager.toMinimalUser({
|
|
952
|
-
authuser: activeAccount.authuser,
|
|
953
|
-
accessToken: activeAccount.accessToken,
|
|
954
|
-
expiresAt: activeAccount.expiresAt,
|
|
955
|
-
sessionId: activeAccount.sessionId,
|
|
956
|
-
user: activeAccount.user,
|
|
957
|
-
});
|
|
958
|
-
await this.writeActiveAuthuser(active);
|
|
959
|
-
|
|
960
|
-
// Schedule auto-refresh on the active slot so the in-memory access
|
|
961
|
-
// token doesn't silently expire under the user.
|
|
962
|
-
if (this.config.autoRefresh) {
|
|
963
|
-
this.setupCookieRefresh(activeAccount.expiresAt, active);
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
// If the active slot has no user shape, schedule a /users/me hydration so
|
|
967
|
-
// the chooser isn't stuck on the public-key handle. Hydration is
|
|
968
|
-
// fire-and-forget — the snapshot is already considered "restored" once
|
|
969
|
-
// the access token is planted.
|
|
970
|
-
if (activeAccount.user === null) {
|
|
971
|
-
slotsNeedingHydration.push(activeAccount.authuser);
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
this._lastRestoreAt.set(active, Date.now());
|
|
976
|
-
this._broadcast({ type: 'accounts_restored', timestamp: Date.now() });
|
|
977
|
-
this.notifyListeners();
|
|
978
|
-
|
|
979
|
-
for (const slot of slotsNeedingHydration) {
|
|
980
|
-
void this._hydrateUnknownUser(slot);
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
return {
|
|
984
|
-
accounts: this.getAccounts(),
|
|
985
|
-
activeAuthuser: this.activeAuthuser,
|
|
986
|
-
};
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
/**
|
|
990
|
-
* Switch the active account to a different device-local slot.
|
|
991
|
-
*
|
|
992
|
-
* Calls `oxyServices.refreshTokenViaCookie({ authuser })` to mint a fresh
|
|
993
|
-
* access token from the slot's httpOnly cookie, updates the in-memory
|
|
994
|
-
* registry entry, plants the token on the HTTP client, persists the new
|
|
995
|
-
* active slot, and broadcasts cross-tab.
|
|
996
|
-
*
|
|
997
|
-
* Throws when the slot's refresh cookie is missing / expired / reused
|
|
998
|
-
* (the SDK returns `null` from `refreshTokenViaCookie` in that case, and
|
|
999
|
-
* we surface it as an `Error` so callers can clean up the slot from
|
|
1000
|
-
* their UI).
|
|
1001
|
-
*/
|
|
1002
|
-
async switchAuthuser(authuser: number): Promise<SwitchAuthuserResult> {
|
|
1003
|
-
// Concurrency gate. Two near-simultaneous switchAuthuser calls would
|
|
1004
|
-
// otherwise both POST /auth/refresh?authuser=N, rotating the slot's
|
|
1005
|
-
// refresh-token family twice and racing on the registry update. The
|
|
1006
|
-
// gate is keyed only by "any switch in flight" — switching to a
|
|
1007
|
-
// DIFFERENT slot while a switch is in flight returns the in-flight
|
|
1008
|
-
// promise (callers can re-issue once it settles if they really meant a
|
|
1009
|
-
// different slot).
|
|
1010
|
-
if (this._switchPromise) {
|
|
1011
|
-
return this._switchPromise;
|
|
1012
|
-
}
|
|
1013
|
-
this._switchPromise = this._doSwitchAuthuser(authuser);
|
|
1014
|
-
try {
|
|
1015
|
-
return await this._switchPromise;
|
|
1016
|
-
} finally {
|
|
1017
|
-
this._switchPromise = null;
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
private async _doSwitchAuthuser(authuser: number): Promise<SwitchAuthuserResult> {
|
|
1022
|
-
const refreshed: RefreshCookieResponse | null = await this.oxyServices.refreshTokenViaCookie({ authuser });
|
|
1023
|
-
if (refreshed === null) {
|
|
1024
|
-
// Drop the dead slot from our registry so the chooser doesn't keep
|
|
1025
|
-
// offering it; callers can drive a `restoreFromCookies()` to
|
|
1026
|
-
// re-sync.
|
|
1027
|
-
this.accounts.delete(authuser);
|
|
1028
|
-
if (this.activeAuthuser === authuser) {
|
|
1029
|
-
this.activeAuthuser = null;
|
|
1030
|
-
}
|
|
1031
|
-
throw new Error(`Refresh cookie for authuser=${authuser} is missing or expired`);
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
// Update (or insert) the slot in the registry. We preserve any user
|
|
1035
|
-
// metadata we already knew from a prior `restoreFromCookies` — the
|
|
1036
|
-
// single-slot refresh endpoint does NOT re-project the user shape. When
|
|
1037
|
-
// we have no prior metadata, we leave `user: null` and schedule a
|
|
1038
|
-
// /users/me hydration below.
|
|
1039
|
-
const existing = this.accounts.get(authuser);
|
|
1040
|
-
const decoded = AuthManager.decodeSessionIdFromAccessToken(refreshed.accessToken);
|
|
1041
|
-
const sessionId = decoded ?? existing?.sessionId ?? '';
|
|
1042
|
-
const updated: AuthManagerAccount = {
|
|
1043
|
-
authuser,
|
|
1044
|
-
sessionId,
|
|
1045
|
-
user: existing?.user ?? null,
|
|
1046
|
-
accessToken: refreshed.accessToken,
|
|
1047
|
-
expiresAt: refreshed.expiresAt,
|
|
1048
|
-
};
|
|
1049
|
-
this.accounts.set(authuser, updated);
|
|
1050
|
-
|
|
1051
|
-
this.activeAuthuser = authuser;
|
|
1052
|
-
this._lastKnownAccessToken = refreshed.accessToken;
|
|
1053
|
-
this.oxyServices.httpService.setTokens(refreshed.accessToken);
|
|
1054
|
-
this.currentUser = updated.user
|
|
1055
|
-
? {
|
|
1056
|
-
id: updated.user.id,
|
|
1057
|
-
username: updated.user.username,
|
|
1058
|
-
name: updated.user.name,
|
|
1059
|
-
avatar: updated.user.avatar ?? undefined,
|
|
1060
|
-
}
|
|
1061
|
-
: null;
|
|
1062
|
-
await this.writeActiveAuthuser(authuser);
|
|
1063
|
-
|
|
1064
|
-
if (this.config.autoRefresh) {
|
|
1065
|
-
this.setupCookieRefresh(refreshed.expiresAt, authuser);
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
this._broadcast({ type: 'authuser_switched', authuser, timestamp: Date.now() });
|
|
1069
|
-
this.notifyListeners();
|
|
1070
|
-
|
|
1071
|
-
if (updated.user === null) {
|
|
1072
|
-
// Fire-and-forget hydration: the switch is considered complete once
|
|
1073
|
-
// the token is planted, the UI uses getAccountFallbackHandle (public-
|
|
1074
|
-
// key fallback) until /users/me resolves.
|
|
1075
|
-
void this._hydrateUnknownUser(authuser);
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
return {
|
|
1079
|
-
accessToken: refreshed.accessToken,
|
|
1080
|
-
expiresAt: refreshed.expiresAt,
|
|
1081
|
-
authuser,
|
|
1082
|
-
};
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
/**
|
|
1086
|
-
* Sign out a single device-local slot.
|
|
1087
|
-
*
|
|
1088
|
-
* Calls `oxyServices.logoutSessionByAuthuser(authuser)`: server-side
|
|
1089
|
-
* revokes the slot's refresh-token family and clears the
|
|
1090
|
-
* `oxy_rt_${authuser}` cookie via `Set-Cookie`. The slot is removed from
|
|
1091
|
-
* the in-memory registry. If the slot was active, the next lowest
|
|
1092
|
-
* remaining authuser becomes active (or `null` when none remain).
|
|
1093
|
-
*/
|
|
1094
|
-
async signOutAuthuser(authuser: number): Promise<void> {
|
|
1095
|
-
try {
|
|
1096
|
-
await this.oxyServices.logoutSessionByAuthuser(authuser);
|
|
1097
|
-
} catch {
|
|
1098
|
-
// Best-effort: the server-side logout is idempotent on unknown
|
|
1099
|
-
// tokens, and we'd rather drop the slot locally than leave dead
|
|
1100
|
-
// state on a network blip.
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
this.accounts.delete(authuser);
|
|
1104
|
-
|
|
1105
|
-
if (this.activeAuthuser === authuser) {
|
|
1106
|
-
const remaining = this.getAccounts();
|
|
1107
|
-
if (remaining.length > 0) {
|
|
1108
|
-
// Pick the lowest remaining authuser as the new active. We don't
|
|
1109
|
-
// proactively refresh its token here — callers can drive
|
|
1110
|
-
// `switchAuthuser` if they need a fresh bearer. This keeps the
|
|
1111
|
-
// method's network footprint to exactly one request.
|
|
1112
|
-
const next = remaining[0];
|
|
1113
|
-
this.activeAuthuser = next.authuser;
|
|
1114
|
-
this._lastKnownAccessToken = next.accessToken;
|
|
1115
|
-
this.oxyServices.httpService.setTokens(next.accessToken);
|
|
1116
|
-
this.currentUser = next.user
|
|
1117
|
-
? {
|
|
1118
|
-
id: next.user.id,
|
|
1119
|
-
username: next.user.username,
|
|
1120
|
-
name: next.user.name,
|
|
1121
|
-
avatar: next.user.avatar ?? undefined,
|
|
1122
|
-
}
|
|
1123
|
-
: null;
|
|
1124
|
-
await this.writeActiveAuthuser(next.authuser);
|
|
1125
|
-
if (next.user === null) {
|
|
1126
|
-
void this._hydrateUnknownUser(next.authuser);
|
|
1127
|
-
}
|
|
1128
|
-
} else {
|
|
1129
|
-
this.activeAuthuser = null;
|
|
1130
|
-
this._lastKnownAccessToken = null;
|
|
1131
|
-
this.oxyServices.httpService.setTokens('');
|
|
1132
|
-
this.currentUser = null;
|
|
1133
|
-
this.currentAuthMethod = null;
|
|
1134
|
-
await this.clearActiveAuthuser();
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
this._broadcast({ type: 'authuser_signed_out', authuser, timestamp: Date.now() });
|
|
1139
|
-
this.notifyListeners();
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
/**
|
|
1143
|
-
* Sign out EVERY device-local account on this device.
|
|
1144
|
-
*
|
|
1145
|
-
* Calls `oxyServices.logoutAllSessionsViaCookie()`: server-side revokes
|
|
1146
|
-
* every presented family and `Set-Cookie`s an immediate expiry for every
|
|
1147
|
-
* recognised `oxy_rt_${n}` slot. The in-memory registry is wiped, the active
|
|
1148
|
-
* slot is cleared, and the persisted `oxy_active_authuser` is removed so the
|
|
1149
|
-
* next cold boot starts fresh.
|
|
1150
|
-
*/
|
|
1151
|
-
async signOutAllViaCookies(): Promise<void> {
|
|
1152
|
-
try {
|
|
1153
|
-
await this.oxyServices.logoutAllSessionsViaCookie();
|
|
1154
|
-
} catch {
|
|
1155
|
-
// Best-effort; server-side endpoint is idempotent.
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
this.accounts.clear();
|
|
1159
|
-
this.activeAuthuser = null;
|
|
1160
|
-
this._lastKnownAccessToken = null;
|
|
1161
|
-
this.oxyServices.httpService.setTokens('');
|
|
1162
|
-
this.currentUser = null;
|
|
1163
|
-
this.currentAuthMethod = null;
|
|
1164
|
-
this._lastRestoreAt.clear();
|
|
1165
|
-
await this.clearActiveAuthuser();
|
|
1166
|
-
|
|
1167
|
-
// Also clear the refresh timer that the cookie path may have scheduled.
|
|
1168
|
-
if (this.refreshTimer) {
|
|
1169
|
-
clearTimeout(this.refreshTimer);
|
|
1170
|
-
this.refreshTimer = null;
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
this._broadcast({ type: 'all_signed_out', timestamp: Date.now() });
|
|
1174
|
-
this.notifyListeners();
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
/**
|
|
1178
|
-
* Schedule an auto-refresh for the cookie path on the active slot. The
|
|
1179
|
-
* AuthManager has exactly one active slot at a time, so one timer suffices.
|
|
1180
|
-
*/
|
|
1181
|
-
private setupCookieRefresh(expiresAt: string, authuser: number): void {
|
|
1182
|
-
if (this.refreshTimer) {
|
|
1183
|
-
clearTimeout(this.refreshTimer);
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
const expiresAtMs = new Date(expiresAt).getTime();
|
|
1187
|
-
if (!Number.isFinite(expiresAtMs)) return;
|
|
1188
|
-
|
|
1189
|
-
const refreshAt = expiresAtMs - this.config.refreshBuffer;
|
|
1190
|
-
const delay = Math.max(0, refreshAt - Date.now());
|
|
1191
|
-
|
|
1192
|
-
this.refreshTimer = setTimeout(() => {
|
|
1193
|
-
// Only refresh if this slot is still the active one when the timer
|
|
1194
|
-
// fires (the user might have switched in the meantime).
|
|
1195
|
-
if (this.activeAuthuser !== authuser) return;
|
|
1196
|
-
this.switchAuthuser(authuser).catch(() => {
|
|
1197
|
-
// A failed cookie refresh on the active slot means the user must
|
|
1198
|
-
// re-auth; surface via `notifyListeners` indirectly when the slot
|
|
1199
|
-
// is dropped from the registry by `switchAuthuser`.
|
|
1200
|
-
});
|
|
1201
|
-
}, delay);
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
/**
|
|
1205
|
-
* Decode the session id from an unverified JWT access token. Decode-only
|
|
1206
|
-
* (no signature verification) — the server already verified the
|
|
1207
|
-
* signature when minting the token. Returns `null` on malformed input.
|
|
1208
|
-
*/
|
|
1209
|
-
private static decodeSessionIdFromAccessToken(token: string): string | null {
|
|
1210
|
-
try {
|
|
1211
|
-
const decoded = jwtDecode<{ sessionId?: string }>(token);
|
|
1212
|
-
return typeof decoded.sessionId === 'string' && decoded.sessionId.length > 0
|
|
1213
|
-
? decoded.sessionId
|
|
1214
|
-
: null;
|
|
1215
|
-
} catch {
|
|
1216
|
-
return null;
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
private static decodeAuthuserFromAccessToken(token: string): number | null {
|
|
1221
|
-
try {
|
|
1222
|
-
const decoded = jwtDecode<{ authuser?: number }>(token);
|
|
1223
|
-
return typeof decoded.authuser === 'number' && Number.isFinite(decoded.authuser) && decoded.authuser >= 0
|
|
1224
|
-
? decoded.authuser
|
|
1225
|
-
: null;
|
|
1226
|
-
} catch {
|
|
1227
|
-
return null;
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
/**
|
|
1232
|
-
* Destroy the auth manager and clean up resources.
|
|
1233
|
-
*/
|
|
1234
|
-
destroy(): void {
|
|
1235
|
-
if (this.refreshTimer) {
|
|
1236
|
-
clearTimeout(this.refreshTimer);
|
|
1237
|
-
this.refreshTimer = null;
|
|
1238
|
-
}
|
|
1239
|
-
this.listeners.clear();
|
|
1240
|
-
this._knownPeerNonces.clear();
|
|
1241
|
-
this._lastRestoreAt.clear();
|
|
1242
|
-
this._switchPromise = null;
|
|
1243
|
-
this.refreshPromise = null;
|
|
1244
|
-
|
|
1245
|
-
// Close BroadcastChannel
|
|
1246
|
-
if (this._broadcastChannel) {
|
|
1247
|
-
try {
|
|
1248
|
-
this._broadcastChannel.close();
|
|
1249
|
-
} catch {
|
|
1250
|
-
// Ignore close errors
|
|
1251
|
-
}
|
|
1252
|
-
this._broadcastChannel = null;
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
/**
|
|
1258
|
-
* Create an AuthManager instance.
|
|
1259
|
-
*
|
|
1260
|
-
* @param oxyServices - OxyServices instance
|
|
1261
|
-
* @param config - Optional configuration
|
|
1262
|
-
* @returns AuthManager instance
|
|
1263
|
-
*/
|
|
1264
|
-
export function createAuthManager(
|
|
1265
|
-
oxyServices: OxyServices,
|
|
1266
|
-
config?: AuthManagerConfig
|
|
1267
|
-
): AuthManager {
|
|
1268
|
-
return new AuthManager(oxyServices, config);
|
|
1269
|
-
}
|