@vibexnpm/talkx 7.3.0 → 8.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/README.md +2 -0
- package/dist/index.d.ts +28 -3
- package/dist/talkflow-sdk.esm.js +1 -1
- package/dist/talkflow-sdk.esm.js.map +1 -1
- package/dist/talkflow-sdk.standalone.js +1 -1
- package/dist/talkflow-sdk.standalone.js.map +1 -1
- package/dist/talkflow-sdk.umd.js +1 -1
- package/dist/talkflow-sdk.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/TalkFlowClient.js +106 -16
- package/src/auth/AuthSession.js +409 -0
- package/src/core/ConnectionManager.js +42 -0
- package/src/push/PushManager.js +2 -0
- package/src/talkflow/session.js +20 -1
- package/src/utils/ApiClient.js +45 -4
- package/src/utils/jwtUtils.js +2 -2
- package/types/__typetests__/authSession.types.ts +8 -0
- package/types/index.d.ts +28 -3
package/package.json
CHANGED
package/src/TalkFlowClient.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import EventEmitter from './utils/EventEmitter.js';
|
|
7
7
|
import Logger from './utils/Logger.js';
|
|
8
8
|
import ApiClient from './utils/ApiClient.js';
|
|
9
|
+
import AuthSession, { tokenClaims } from './auth/AuthSession.js';
|
|
9
10
|
import { extractUserIdFromJWT } from './utils/jwtUtils.js';
|
|
10
11
|
import { ConnectionState, ErrorTypes, DefaultConfig, LogLevel, Environment, getServerUrl } from './constants.js';
|
|
11
12
|
import { initializeSubClients, applySessionMethods } from './talkflow/session.js';
|
|
@@ -66,6 +67,7 @@ class TalkFlowClient extends EventEmitter {
|
|
|
66
67
|
apiKey: options.apiKey,
|
|
67
68
|
projectId: options.projectId,
|
|
68
69
|
jwtToken: options.jwtToken || null,
|
|
70
|
+
sessionMode: options.sessionMode || (tokenClaims(options.jwtToken || '').sid ? 'refresh' : 'legacy'),
|
|
69
71
|
useSockJS: options.useSockJS !== false,
|
|
70
72
|
reconnectDelay: options.reconnectDelay || DefaultConfig.reconnectDelay,
|
|
71
73
|
maxReconnectDelay: options.maxReconnectDelay || DefaultConfig.maxReconnectDelay,
|
|
@@ -121,6 +123,8 @@ class TalkFlowClient extends EventEmitter {
|
|
|
121
123
|
});
|
|
122
124
|
|
|
123
125
|
// ConnectionManager, ChatClient, WebRTCClient는 지연 초기화
|
|
126
|
+
this.authSession = null;
|
|
127
|
+
this._authConnectionRequested = false;
|
|
124
128
|
this.connectionManager = null;
|
|
125
129
|
this._chat = null;
|
|
126
130
|
this._webrtc = null;
|
|
@@ -133,6 +137,7 @@ class TalkFlowClient extends EventEmitter {
|
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
this._initialized = true;
|
|
140
|
+
if (this.options.sessionMode === 'refresh') this._enableRefreshSession();
|
|
136
141
|
this.logger.info('TalkFlowClient initialized', {
|
|
137
142
|
userId: this._userId,
|
|
138
143
|
hasToken: !!this.options.jwtToken
|
|
@@ -163,16 +168,16 @@ class TalkFlowClient extends EventEmitter {
|
|
|
163
168
|
if (!options) {
|
|
164
169
|
throw new Error('Options are required');
|
|
165
170
|
}
|
|
166
|
-
if (!options.apiKey) {
|
|
171
|
+
if (!options.apiKey && options.sessionMode !== 'refresh') {
|
|
167
172
|
throw new Error('apiKey is required');
|
|
168
173
|
}
|
|
169
|
-
if (!options.projectId) {
|
|
174
|
+
if (!options.projectId && options.sessionMode !== 'refresh') {
|
|
170
175
|
throw new Error('projectId is required');
|
|
171
176
|
}
|
|
172
177
|
|
|
173
178
|
// Server Key (sk-) 는 SDK 가 쓸 일이 없다 — 유일한 용도였던 registerUser(/users/auth) 가
|
|
174
179
|
// 사라졌다. 그래도 들어오면 잘못된 키를 넣은 것이므로 알린다.
|
|
175
|
-
if (options.apiKey
|
|
180
|
+
if (options.apiKey?.startsWith('sk-')) {
|
|
176
181
|
console.error(
|
|
177
182
|
'🚨 [TalkFlow] Server Key (sk-) 로 SDK 를 초기화했습니다. ' +
|
|
178
183
|
'SDK 는 Client Key (ck-) 만 사용합니다 — loginUser() 응답의 clientKey 를 그대로 쓰세요.'
|
|
@@ -204,6 +209,82 @@ class TalkFlowClient extends EventEmitter {
|
|
|
204
209
|
return this.connectionManager ? this.connectionManager.isConnected() : false;
|
|
205
210
|
}
|
|
206
211
|
|
|
212
|
+
_enableRefreshSession() {
|
|
213
|
+
if (this.authSession) return;
|
|
214
|
+
this.options.sessionMode = 'refresh';
|
|
215
|
+
this.authSession = AuthSession.forServer(this.options.serverUrl);
|
|
216
|
+
this.authSession.attach(this);
|
|
217
|
+
this.apiClient.authProvider = this.authSession;
|
|
218
|
+
this._authUnsubscribe = this.authSession.on('authStateChanged', event => this.emit('authStateChanged', event));
|
|
219
|
+
this._authErrorUnsubscribe = this.authSession.on('authError', error => this.emit('authError', error));
|
|
220
|
+
if (!this.authSession.data && this.options.jwtToken && tokenClaims(this.options.jwtToken).sid) {
|
|
221
|
+
this.authSession.data = { accessToken: this.options.jwtToken, projectId: this.options.projectId,
|
|
222
|
+
clientKey: this.options.apiKey, user: { userId: this.userId } };
|
|
223
|
+
this.authSession._setState('authenticated');
|
|
224
|
+
}
|
|
225
|
+
this._authReady = Promise.resolve();
|
|
226
|
+
if (this.authSession.data) this._authReady = this._acceptAuthSession(this.authSession.data).catch(error => {
|
|
227
|
+
this._authReady = Promise.resolve();
|
|
228
|
+
throw error;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async _acceptAuthSession(data) {
|
|
233
|
+
if (this.options.projectId && this.options.projectId !== data.projectId && this.connectionManager) {
|
|
234
|
+
await this._clearAuthSession();
|
|
235
|
+
}
|
|
236
|
+
this.options.apiKey = data.clientKey;
|
|
237
|
+
this.options.projectId = data.projectId;
|
|
238
|
+
this.apiClient.apiKey = data.clientKey;
|
|
239
|
+
this.apiClient.projectId = data.projectId;
|
|
240
|
+
await this.setToken(data.accessToken);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async _clearAuthSession() {
|
|
244
|
+
this._authConnectionRequested = false;
|
|
245
|
+
this.options.jwtToken = null;
|
|
246
|
+
this._userId = null;
|
|
247
|
+
this.apiClient.setJwtToken(null);
|
|
248
|
+
await this.disconnect();
|
|
249
|
+
this._chat?.destroy();
|
|
250
|
+
this._webrtc?.destroy();
|
|
251
|
+
await this.connectionManager?.destroy();
|
|
252
|
+
this._chat = null;
|
|
253
|
+
this._webrtc = null;
|
|
254
|
+
this.connectionManager = null;
|
|
255
|
+
this.pushManager?.reset();
|
|
256
|
+
this._pushManager = null;
|
|
257
|
+
this._pushEnablePromise = null;
|
|
258
|
+
this.emit('loggedOut');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
getAuthState() {
|
|
262
|
+
return this.authSession?.getAuthState()
|
|
263
|
+
|| (Number(tokenClaims(this.options.jwtToken || '').exp || 0) * 1000 > Date.now() ? 'authenticated' : 'unauthenticated');
|
|
264
|
+
}
|
|
265
|
+
isLoggedIn() { return this.getAuthState() === 'authenticated'; }
|
|
266
|
+
async getAccessToken(options = {}) {
|
|
267
|
+
if (this.authSession) { await this._authReady; return this.authSession.getAccessToken(options); }
|
|
268
|
+
if (!this.isLoggedIn()) { const error = new Error('Sign in is required'); error.code = 'SESSION_REVOKED'; throw error; }
|
|
269
|
+
return this.options.jwtToken;
|
|
270
|
+
}
|
|
271
|
+
async restoreSession() {
|
|
272
|
+
this._enableRefreshSession();
|
|
273
|
+
await this._authReady;
|
|
274
|
+
return this.authSession.restoreSession();
|
|
275
|
+
}
|
|
276
|
+
static async restoreSession({ serverUrl }) {
|
|
277
|
+
if (!serverUrl) throw new Error('serverUrl is required');
|
|
278
|
+
return AuthSession.forServer(serverUrl).restoreSession();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** FE의 관리/캘린더 직접 호출도 동일한 인증·1회 재시도 경로를 사용한다. */
|
|
282
|
+
async request(method, path, options = {}) {
|
|
283
|
+
if (!path.startsWith('/') || path.startsWith('//')) throw new Error('Use an API-relative path');
|
|
284
|
+
await this._authReady;
|
|
285
|
+
return this.apiClient.request(method, path, options);
|
|
286
|
+
}
|
|
287
|
+
|
|
207
288
|
/**
|
|
208
289
|
* 현재 연결 상태
|
|
209
290
|
* @returns {string}
|
|
@@ -257,9 +338,7 @@ class TalkFlowClient extends EventEmitter {
|
|
|
257
338
|
if (!password) throw new Error('password is required');
|
|
258
339
|
|
|
259
340
|
// 로그인은 키를 «받기 위해» 부르는 경로라 X-API-KEY/X-PROJECT-ID 가 없다(서버에서 필터 제외).
|
|
260
|
-
|
|
261
|
-
const response = await client.post('/api/v1/auth/login', { email, password });
|
|
262
|
-
return (response && response.data) ? response.data : response;
|
|
341
|
+
return AuthSession.forServer(serverUrl).login({ email, password });
|
|
263
342
|
}
|
|
264
343
|
|
|
265
344
|
/**
|
|
@@ -274,14 +353,10 @@ class TalkFlowClient extends EventEmitter {
|
|
|
274
353
|
* @returns {Promise<Object>} 로그인 응답
|
|
275
354
|
*/
|
|
276
355
|
async loginUser({ email, password }) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
await this.setToken(data.accessToken);
|
|
282
|
-
this.logger.info('Member logged in, token auto-set');
|
|
283
|
-
}
|
|
284
|
-
return data;
|
|
356
|
+
if (!email || !password) throw new Error('email and password are required');
|
|
357
|
+
this._enableRefreshSession();
|
|
358
|
+
await this._authReady;
|
|
359
|
+
return this.authSession.login({ email, password });
|
|
285
360
|
}
|
|
286
361
|
|
|
287
362
|
/**
|
|
@@ -354,7 +429,7 @@ class TalkFlowClient extends EventEmitter {
|
|
|
354
429
|
* 비밀번호 변경 — 현재 비밀번호를 재확인한다.
|
|
355
430
|
*
|
|
356
431
|
* <p>토큰만 탈취해도 비밀번호를 바꿀 수 있으면 계정을 통째로 빼앗긴다. 비밀번호는 바뀌어도
|
|
357
|
-
*
|
|
432
|
+
* 현재 클라이언트는 새 세션을 받아 유지하고, 다른 세션은 종료한다.</p>
|
|
358
433
|
*
|
|
359
434
|
* @param {Object} params
|
|
360
435
|
* @param {string} params.currentPassword
|
|
@@ -362,7 +437,19 @@ class TalkFlowClient extends EventEmitter {
|
|
|
362
437
|
*/
|
|
363
438
|
async changeMyPassword({ currentPassword, newPassword }) {
|
|
364
439
|
this._checkToken();
|
|
365
|
-
|
|
440
|
+
if (this.authSession) {
|
|
441
|
+
return this.authSession.changePassword(() => this.apiClient.request('PUT', '/api/v1/me/password', {
|
|
442
|
+
body: { currentPassword, newPassword }, skipAuth: true
|
|
443
|
+
}));
|
|
444
|
+
}
|
|
445
|
+
const response = await this.apiClient.put('/api/v1/me/password', { currentPassword, newPassword });
|
|
446
|
+
const data = response?.data ?? response;
|
|
447
|
+
if (data?.accessToken && data?.refreshToken) {
|
|
448
|
+
this._enableRefreshSession();
|
|
449
|
+
this.authSession._newMarker(false);
|
|
450
|
+
await this.authSession._accept(data);
|
|
451
|
+
}
|
|
452
|
+
return response;
|
|
366
453
|
}
|
|
367
454
|
|
|
368
455
|
/**
|
|
@@ -586,6 +673,9 @@ class TalkFlowClient extends EventEmitter {
|
|
|
586
673
|
*/
|
|
587
674
|
async destroy() {
|
|
588
675
|
await this.disconnect();
|
|
676
|
+
this._authUnsubscribe?.();
|
|
677
|
+
this._authErrorUnsubscribe?.();
|
|
678
|
+
this.authSession?.detach(this);
|
|
589
679
|
|
|
590
680
|
if (this.chat) {
|
|
591
681
|
this.chat.destroy();
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import EventEmitter from '../utils/EventEmitter.js';
|
|
2
|
+
|
|
3
|
+
const sessions = new Map();
|
|
4
|
+
const TERMINAL_CODES = new Set(['REFRESH_INVALID', 'REFRESH_REUSED', 'SESSION_REVOKED', 'ACCOUNT_BLOCKED', 'LOGIN_FAILED']);
|
|
5
|
+
export const isTerminalAuthError = error => TERMINAL_CODES.has(error?.response?.code || error?.code);
|
|
6
|
+
|
|
7
|
+
export function tokenClaims(token) {
|
|
8
|
+
try {
|
|
9
|
+
const value = token.replace(/^Bearer\s+/i, '').split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
10
|
+
return JSON.parse(atob(value));
|
|
11
|
+
} catch { return {}; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 서버별 인증 상태. Access는 메모리, Refresh와 로그아웃 의도는 하나의 저장 레코드로 관리한다. */
|
|
15
|
+
export default class AuthSession extends EventEmitter {
|
|
16
|
+
static forServer(serverUrl) {
|
|
17
|
+
const url = serverUrl.replace(/\/$/, '');
|
|
18
|
+
if (!sessions.has(url)) sessions.set(url, new AuthSession(url));
|
|
19
|
+
return sessions.get(url);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
constructor(serverUrl) {
|
|
23
|
+
super();
|
|
24
|
+
this.serverUrl = serverUrl;
|
|
25
|
+
this.state = 'checking';
|
|
26
|
+
this.data = null;
|
|
27
|
+
this.clients = new Set();
|
|
28
|
+
this.generation = 0;
|
|
29
|
+
this.refreshPromise = null;
|
|
30
|
+
this.timer = null;
|
|
31
|
+
this.peerWaiters = new Map();
|
|
32
|
+
this.markerKey = `talkflow:auth:${serverUrl}`;
|
|
33
|
+
this.marker = this._readMarker();
|
|
34
|
+
this.serverClock = null;
|
|
35
|
+
this.channel = null;
|
|
36
|
+
this.storageListener = event => {
|
|
37
|
+
if (event.key !== this.markerKey) return;
|
|
38
|
+
const stored = this._readMarker();
|
|
39
|
+
if (stored.revision !== this.marker.revision || stored.logout) {
|
|
40
|
+
this.marker = stored;
|
|
41
|
+
this.generation++;
|
|
42
|
+
this._clear(false).catch(error => this.emit('authError', error));
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
globalThis.addEventListener?.('storage', this.storageListener);
|
|
46
|
+
if (typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined') {
|
|
47
|
+
this.channel = new BroadcastChannel(`talkflow:auth:${serverUrl}`);
|
|
48
|
+
this.channel.onmessage = event => this._receive(event.data);
|
|
49
|
+
this.channel.unref?.();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_readMarker() {
|
|
54
|
+
try {
|
|
55
|
+
const value = globalThis.localStorage?.getItem(this.markerKey);
|
|
56
|
+
return value ? JSON.parse(value) : { revision: 'initial', logout: false };
|
|
57
|
+
} catch { throw this._storageError(); }
|
|
58
|
+
}
|
|
59
|
+
_storageError() { const error = new Error('Authentication storage is unavailable'); error.code = 'AUTH_STORAGE_UNAVAILABLE'; return error; }
|
|
60
|
+
_writeMarker(marker) {
|
|
61
|
+
try {
|
|
62
|
+
if (!globalThis.localStorage) throw this._storageError();
|
|
63
|
+
globalThis.localStorage.setItem(this.markerKey, JSON.stringify(marker));
|
|
64
|
+
this.marker = marker;
|
|
65
|
+
} catch { throw this._storageError(); }
|
|
66
|
+
}
|
|
67
|
+
_newMarker(logout, pending = logout) {
|
|
68
|
+
const previous = this._readMarker();
|
|
69
|
+
const marker = { revision: globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(), logout,
|
|
70
|
+
deviceId: previous.deviceId, pending,
|
|
71
|
+
pendingRefreshToken: logout && pending ? previous.refreshToken || previous.pendingRefreshToken : undefined };
|
|
72
|
+
this._writeMarker(marker);
|
|
73
|
+
return marker;
|
|
74
|
+
}
|
|
75
|
+
setDeviceId(deviceId) { this._writeMarker({ ...this._readMarker(), deviceId }); }
|
|
76
|
+
_monotonic() { return globalThis.performance?.now?.() ?? Date.now(); }
|
|
77
|
+
_serverNow() {
|
|
78
|
+
if (!this.serverClock) return Date.now();
|
|
79
|
+
return this.serverClock.server + Math.max(this._monotonic() - this.serverClock.mono, Date.now() - this.serverClock.wall);
|
|
80
|
+
}
|
|
81
|
+
_shareData() { return this.data ? { ...this.data, refreshToken: undefined, serverTime: this._serverNow() } : null; }
|
|
82
|
+
_publish(type, data, marker = this.marker) {
|
|
83
|
+
const { revision, logout, pending } = marker;
|
|
84
|
+
this.channel?.postMessage({ type, marker: { revision, logout, pending }, data });
|
|
85
|
+
}
|
|
86
|
+
_setState(state, error, force = false) {
|
|
87
|
+
const previous = this.state;
|
|
88
|
+
this.state = state;
|
|
89
|
+
if (previous !== state || error || force) this.emit('authStateChanged', { state, previous, error });
|
|
90
|
+
}
|
|
91
|
+
getAuthState() { return this.state; }
|
|
92
|
+
isLoggedIn() { return this.state === 'authenticated'; }
|
|
93
|
+
_fresh(token = this.data?.accessToken) { return Number(tokenClaims(token || '').exp || 0) * 1000 > this._serverNow() + (this.refreshMargin ?? 60000); }
|
|
94
|
+
|
|
95
|
+
attach(client) { this.clients.add(client); }
|
|
96
|
+
detach(client) { this.clients.delete(client); this.schedule(); }
|
|
97
|
+
|
|
98
|
+
_enqueue(work) {
|
|
99
|
+
const operation = (this.transition || Promise.resolve()).then(work);
|
|
100
|
+
this.transition = operation.catch(() => {});
|
|
101
|
+
return operation;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_accept(data, publish = true) {
|
|
105
|
+
const generation = this.generation;
|
|
106
|
+
const marker = this.marker;
|
|
107
|
+
return this._enqueue(async () => {
|
|
108
|
+
if (generation !== this.generation) throw this._cancelled();
|
|
109
|
+
if (!data?.accessToken || !data?.projectId || !data?.clientKey || !data?.user?.userId) {
|
|
110
|
+
const error = new Error('Invalid authentication response'); error.code = 'AUTH_UNAVAILABLE'; throw error;
|
|
111
|
+
}
|
|
112
|
+
const identityChanged = this.data && (this.data.user.userId !== data.user.userId || this.data.projectId !== data.projectId);
|
|
113
|
+
if (publish && data.refreshToken) {
|
|
114
|
+
const stored = this._readMarker();
|
|
115
|
+
if (stored.revision !== marker.revision || stored.logout) throw this._cancelled();
|
|
116
|
+
this._writeMarker({ ...stored, refreshToken: data.refreshToken, refreshRequestId: undefined, pending: false, pendingRefreshToken: undefined });
|
|
117
|
+
}
|
|
118
|
+
if (Number.isFinite(data.serverTime)) this.serverClock = {
|
|
119
|
+
server: data.serverTime + (data._roundTripMillis || 0), mono: this._monotonic(), wall: Date.now()
|
|
120
|
+
};
|
|
121
|
+
this.refreshMargin = Math.min(60000, Math.max(0, Number(tokenClaims(data.accessToken).exp) * 1000 - this._serverNow()) / 10);
|
|
122
|
+
// Access 응답만 메모리에 남긴다. Refresh는 매 갱신 시 저장소에서 다시 읽는다.
|
|
123
|
+
this.data = { ...data, refreshToken: undefined };
|
|
124
|
+
await Promise.all([...this.clients].map(client => client._acceptAuthSession(data)));
|
|
125
|
+
if (generation !== this.generation) throw this._cancelled();
|
|
126
|
+
this._setState('authenticated', undefined, identityChanged);
|
|
127
|
+
if (publish) this._publish('tokens', this._shareData(), this.marker);
|
|
128
|
+
this._reconnectClients();
|
|
129
|
+
this.schedule();
|
|
130
|
+
return data;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_reconnectClients() {
|
|
135
|
+
for (const client of this.clients) {
|
|
136
|
+
if (client._authConnectionRequested && client.isConnected?.()) {
|
|
137
|
+
const manager = client.connectionManager;
|
|
138
|
+
if (manager?._authenticatedToken !== this.data?.accessToken) {
|
|
139
|
+
manager.forceReconnect('access-token-renewed').catch(error => this.emit('authError', error));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_clear(publish = true) {
|
|
146
|
+
const marker = this.marker;
|
|
147
|
+
return this._enqueue(async () => {
|
|
148
|
+
this.data = null;
|
|
149
|
+
clearTimeout(this.timer);
|
|
150
|
+
this.timer = null;
|
|
151
|
+
this._setState('unauthenticated');
|
|
152
|
+
await Promise.all([...this.clients].map(client => client._clearAuthSession()));
|
|
153
|
+
if (publish) this._publish('logout', undefined, marker);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async _receive(message) {
|
|
158
|
+
if (!message?.marker) return;
|
|
159
|
+
try {
|
|
160
|
+
const stored = this._readMarker();
|
|
161
|
+
if (message.marker.revision !== stored.revision && stored.revision !== this.marker.revision) return;
|
|
162
|
+
if (message.type === 'logout' || message.type === 'login-start') {
|
|
163
|
+
if (message.marker.revision !== stored.revision) return;
|
|
164
|
+
this.marker = message.marker;
|
|
165
|
+
this.generation++;
|
|
166
|
+
await this._clear(false);
|
|
167
|
+
if (message.type === 'login-start') this._setState('checking');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (message.marker.revision !== this._readMarker().revision || message.marker.logout) return;
|
|
171
|
+
if (message.type === 'request-token' && this._fresh()) {
|
|
172
|
+
this._publish('peer-token', { requestId: message.data.requestId, session: this._shareData() });
|
|
173
|
+
} else if (message.type === 'peer-token') {
|
|
174
|
+
this.peerWaiters.get(message.data.requestId)?.(message.data.session);
|
|
175
|
+
} else if (message.type === 'tokens') {
|
|
176
|
+
if (this.marker.revision !== message.marker.revision) this.generation++;
|
|
177
|
+
this.marker = message.marker;
|
|
178
|
+
// 지연된 방송이 더 최근 토큰을 덮어쓰지 않도록 한다.
|
|
179
|
+
const incoming = tokenClaims(message.data?.accessToken || '');
|
|
180
|
+
const current = tokenClaims(this.data?.accessToken || '');
|
|
181
|
+
if (incoming.sid === current.sid && incoming.exp < current.exp) return;
|
|
182
|
+
await this._accept(message.data, false);
|
|
183
|
+
}
|
|
184
|
+
} catch (error) { this.emit('authError', error); }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async _peerToken(rejectedToken) {
|
|
188
|
+
if (!this.channel) return null;
|
|
189
|
+
const requestId = `${Date.now()}-${Math.random()}`;
|
|
190
|
+
return new Promise(resolve => {
|
|
191
|
+
const done = data => {
|
|
192
|
+
if (data && (data.accessToken === rejectedToken || Number(tokenClaims(data.accessToken).exp) * 1000 <= Number(data.serverTime) + 60000)) return;
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
this.peerWaiters.delete(requestId);
|
|
195
|
+
resolve(data);
|
|
196
|
+
};
|
|
197
|
+
const timer = setTimeout(() => done(null), 100);
|
|
198
|
+
this.peerWaiters.set(requestId, done);
|
|
199
|
+
this._publish('request-token', { requestId });
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_locked(work) {
|
|
204
|
+
const locks = typeof navigator !== 'undefined' ? navigator.locks : null;
|
|
205
|
+
const operation = (this.httpQueue || Promise.resolve()).then(() =>
|
|
206
|
+
locks?.request ? locks.request(`talkflow:auth:${this.serverUrl}`, work) : work());
|
|
207
|
+
this.httpQueue = operation.catch(() => {});
|
|
208
|
+
return operation;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_newRefreshRequestId() {
|
|
212
|
+
const bytes = new Uint8Array(32);
|
|
213
|
+
if (!globalThis.crypto?.getRandomValues) {
|
|
214
|
+
const error = new Error('Secure randomness is unavailable'); error.code = 'AUTH_RANDOM_UNAVAILABLE'; throw error;
|
|
215
|
+
}
|
|
216
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
217
|
+
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async _request(path, body, headers = {}) {
|
|
221
|
+
const started = this._monotonic();
|
|
222
|
+
const controller = new AbortController();
|
|
223
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
224
|
+
try {
|
|
225
|
+
const response = await fetch(`${this.serverUrl}${path}`, {
|
|
226
|
+
method: 'POST', credentials: 'omit', signal: controller.signal,
|
|
227
|
+
headers: { 'Content-Type': 'application/json', 'X-TalkFlow-Session': 'refresh', ...headers },
|
|
228
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
229
|
+
});
|
|
230
|
+
const text = response.headers.get('content-type')?.includes('application/json')
|
|
231
|
+
? await response.json() : await response.text();
|
|
232
|
+
if (!response.ok) {
|
|
233
|
+
const error = new Error(text?.message || `HTTP ${response.status}`);
|
|
234
|
+
error.status = response.status; error.code = text?.code; error.response = text;
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
const data = text?.data !== undefined ? text.data : text;
|
|
238
|
+
if (data && typeof data === 'object') return Object.defineProperty({ ...data }, '_roundTripMillis', { value: this._monotonic() - started });
|
|
239
|
+
return data;
|
|
240
|
+
} finally { clearTimeout(timeout); }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async login({ email, password }) {
|
|
244
|
+
const generation = ++this.generation;
|
|
245
|
+
return this._locked(async () => {
|
|
246
|
+
const previous = this._readMarker();
|
|
247
|
+
this._writeMarker(previous);
|
|
248
|
+
if (previous.pendingRefreshToken) await this._request('/v1/auth/signout', { refreshToken: previous.pendingRefreshToken, deviceId: previous.deviceId });
|
|
249
|
+
const data = await this._request('/api/v1/auth/login', { email, password, previousRefreshToken: previous.refreshToken });
|
|
250
|
+
if (!data?.refreshToken) {
|
|
251
|
+
const error = new Error('Server does not support the refresh session contract');
|
|
252
|
+
error.code = 'AUTH_PROTOCOL_UNSUPPORTED';
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
if (generation !== this.generation || this._readMarker().revision !== previous.revision) {
|
|
256
|
+
await this._request('/v1/auth/signout', { refreshToken: data.refreshToken });
|
|
257
|
+
throw this._cancelled();
|
|
258
|
+
}
|
|
259
|
+
this._newMarker(false);
|
|
260
|
+
return this._accept(data);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async restoreSession() {
|
|
265
|
+
if (this._readMarker().logout) {
|
|
266
|
+
if (this._readMarker().pending) await this.logout();
|
|
267
|
+
else await this._clear(false);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
if (this._fresh()) return this.data;
|
|
271
|
+
if (!this._readMarker().refreshToken) { await this._clear(false); return null; }
|
|
272
|
+
if (!this.data) this._setState('checking');
|
|
273
|
+
try { await this.getAccessToken(); return this.data; }
|
|
274
|
+
catch (error) { if (isTerminalAuthError(error)) return null; throw error; }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async getAccessToken({ forceRefresh = false, rejectedToken } = {}) {
|
|
278
|
+
if (this._readMarker().logout) throw this._loggedOut();
|
|
279
|
+
if (this._fresh() && (!forceRefresh || (rejectedToken && rejectedToken !== this.data.accessToken)))
|
|
280
|
+
return this.data.accessToken;
|
|
281
|
+
if (this.refreshPromise) return this.refreshPromise;
|
|
282
|
+
const generation = this.generation;
|
|
283
|
+
const revision = this._readMarker().revision;
|
|
284
|
+
const operation = this._locked(async () => {
|
|
285
|
+
if (generation !== this.generation || revision !== this._readMarker().revision) throw this._cancelled();
|
|
286
|
+
if (this._fresh() && (!forceRefresh || (rejectedToken && rejectedToken !== this.data.accessToken)))
|
|
287
|
+
return this.data.accessToken;
|
|
288
|
+
this.marker = this._readMarker();
|
|
289
|
+
if (!this.marker.refreshToken) throw this._loggedOut();
|
|
290
|
+
// 회전 전에 저장 가능 여부를 확인한다. 잠금 대기 전 읽은 토큰은 사용하지 않는다.
|
|
291
|
+
this._writeMarker(this.marker);
|
|
292
|
+
const peer = await this._peerToken(rejectedToken);
|
|
293
|
+
let data = peer;
|
|
294
|
+
if (!data) {
|
|
295
|
+
// 요청 전에 자격과 식별자를 함께 저장한다. 실패·재시작 뒤에도 같은 요청으로 복구한다.
|
|
296
|
+
const stored = this._readMarker();
|
|
297
|
+
if (generation !== this.generation || revision !== stored.revision || stored.logout) throw this._cancelled();
|
|
298
|
+
const requestId = stored.refreshRequestId || this._newRefreshRequestId();
|
|
299
|
+
this._writeMarker({ ...stored, refreshRequestId: requestId });
|
|
300
|
+
data = await this._request('/api/v1/auth/refresh', { refreshToken: stored.refreshToken, requestId });
|
|
301
|
+
}
|
|
302
|
+
if (generation !== this.generation || revision !== this._readMarker().revision) throw this._cancelled();
|
|
303
|
+
await this._accept(data, !peer);
|
|
304
|
+
return data.accessToken;
|
|
305
|
+
}).catch(async error => {
|
|
306
|
+
if (generation === this.generation && revision === this._readMarker().revision) {
|
|
307
|
+
if (isTerminalAuthError(error)) await this.authenticationFailed(error);
|
|
308
|
+
else if (!this.data) this._setState('unavailable', error);
|
|
309
|
+
else this.emit('authError', error);
|
|
310
|
+
}
|
|
311
|
+
throw error;
|
|
312
|
+
}).finally(() => { if (this.refreshPromise === operation) this.refreshPromise = null; });
|
|
313
|
+
this.refreshPromise = operation;
|
|
314
|
+
return operation;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async logout() {
|
|
318
|
+
const token = this.data?.accessToken;
|
|
319
|
+
this.generation++;
|
|
320
|
+
const marker = this._newMarker(true);
|
|
321
|
+
// 로컬 정리보다 먼저 HTTP 순서를 예약해 뒤의 로그인이 로그아웃을 추월하지 못하게 한다.
|
|
322
|
+
const cleanup = this._clear();
|
|
323
|
+
const request = this._locked(async () => {
|
|
324
|
+
const result = await this._request('/v1/auth/signout', { refreshToken: marker.pendingRefreshToken, deviceId: marker.deviceId },
|
|
325
|
+
token ? { Authorization: 'Bearer ' + token.replace(/^Bearer\s+/i, '') } : {});
|
|
326
|
+
if (this._readMarker().revision === marker.revision) this._writeMarker({ ...marker, pending: false, pendingRefreshToken: undefined });
|
|
327
|
+
return result;
|
|
328
|
+
});
|
|
329
|
+
await Promise.all([cleanup, request]);
|
|
330
|
+
if (this._readMarker().revision === marker.revision) {
|
|
331
|
+
this._writeMarker({ ...marker, pending: false, pendingRefreshToken: undefined });
|
|
332
|
+
}
|
|
333
|
+
// 명시적 로그인 전에는 재시작해도 로그아웃 의도를 유지한다.
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async changePassword(request) {
|
|
337
|
+
await this.getAccessToken();
|
|
338
|
+
const generation = ++this.generation;
|
|
339
|
+
return this._locked(async () => {
|
|
340
|
+
const response = await request();
|
|
341
|
+
if (generation !== this.generation) throw this._cancelled();
|
|
342
|
+
const data = response?.data ?? response;
|
|
343
|
+
if (data?.reauthenticationRequired) {
|
|
344
|
+
this._newMarker(true, false);
|
|
345
|
+
await this._clear();
|
|
346
|
+
return response;
|
|
347
|
+
}
|
|
348
|
+
this._newMarker(false);
|
|
349
|
+
await this._accept(data);
|
|
350
|
+
return response;
|
|
351
|
+
}).catch(async error => {
|
|
352
|
+
if (generation === this.generation) await this.authenticationFailed(error);
|
|
353
|
+
throw error;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_loggedOut() { const error = new Error('Sign in is required'); error.code = 'SESSION_REVOKED'; return error; }
|
|
358
|
+
async authenticationFailed(error, { rejectedToken, generation } = {}) {
|
|
359
|
+
if (generation !== undefined && generation !== this.generation) return;
|
|
360
|
+
if (rejectedToken && this.data && rejectedToken !== this.data.accessToken) return;
|
|
361
|
+
if (isTerminalAuthError(error)) {
|
|
362
|
+
this.generation++;
|
|
363
|
+
this._newMarker(true, false);
|
|
364
|
+
await this._clear();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
replaceAccessToken(accessToken) {
|
|
368
|
+
if (!this.data || this.data.accessToken === accessToken) return;
|
|
369
|
+
const claims = tokenClaims(accessToken);
|
|
370
|
+
if (claims.sid !== tokenClaims(this.data.accessToken).sid || claims.sub !== this.data.user.userId) return;
|
|
371
|
+
this.data = { ...this.data, accessToken, expiresIn: claims.exp };
|
|
372
|
+
this._publish('tokens', this._shareData());
|
|
373
|
+
this._reconnectClients();
|
|
374
|
+
this.schedule();
|
|
375
|
+
}
|
|
376
|
+
_cancelled() { const error = new Error('Authentication operation was superseded'); error.code = 'AUTH_OPERATION_CANCELLED'; return error; }
|
|
377
|
+
|
|
378
|
+
schedule(delay) {
|
|
379
|
+
clearTimeout(this.timer);
|
|
380
|
+
this.timer = null;
|
|
381
|
+
if (!this.data || ![...this.clients].some(client => client._authConnectionRequested)) return;
|
|
382
|
+
const wait = delay ?? Math.max(1000, Number(tokenClaims(this.data.accessToken).exp || 0) * 1000 - this._serverNow() - (this.refreshMargin ?? 60000));
|
|
383
|
+
this.timer = setTimeout(async () => {
|
|
384
|
+
try {
|
|
385
|
+
const before = this.data?.accessToken;
|
|
386
|
+
await this.getAccessToken({ forceRefresh: true, rejectedToken: before });
|
|
387
|
+
if (this.data) {
|
|
388
|
+
await Promise.all([...this.clients].filter(client => client._authConnectionRequested
|
|
389
|
+
&& !client.isConnected())
|
|
390
|
+
.map(client => client.connectionManager.forceReconnect('access-token-renewed')));
|
|
391
|
+
}
|
|
392
|
+
this.schedule();
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (!isTerminalAuthError(error)) this.schedule(30000);
|
|
395
|
+
}
|
|
396
|
+
}, wait);
|
|
397
|
+
this.timer.unref?.();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
destroy() {
|
|
401
|
+
this.generation++;
|
|
402
|
+
clearTimeout(this.timer);
|
|
403
|
+
this.channel?.close();
|
|
404
|
+
globalThis.removeEventListener?.('storage', this.storageListener);
|
|
405
|
+
this.clients.clear();
|
|
406
|
+
this.removeAllListeners();
|
|
407
|
+
if (sessions.get(this.serverUrl) === this) sessions.delete(this.serverUrl);
|
|
408
|
+
}
|
|
409
|
+
}
|