@codingfactory/socialkit-vue 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/composables/useAuth.d.ts +27 -0
- package/dist/composables/useAuth.d.ts.map +1 -0
- package/dist/composables/useAuth.js +137 -0
- package/dist/composables/useAuth.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/terminology/index.d.ts +11 -0
- package/dist/plugins/terminology/index.d.ts.map +1 -0
- package/dist/plugins/terminology/index.js +91 -0
- package/dist/plugins/terminology/index.js.map +1 -0
- package/dist/plugins/terminology/terms.d.ts +15 -0
- package/dist/plugins/terminology/terms.d.ts.map +1 -0
- package/dist/plugins/terminology/terms.js +72 -0
- package/dist/plugins/terminology/terms.js.map +1 -0
- package/dist/plugins/terminology/types.d.ts +32 -0
- package/dist/plugins/terminology/types.d.ts.map +1 -0
- package/dist/plugins/terminology/types.js +2 -0
- package/dist/plugins/terminology/types.js.map +1 -0
- package/dist/services/api.d.ts +50 -0
- package/dist/services/api.d.ts.map +1 -0
- package/dist/services/api.js +305 -0
- package/dist/services/api.js.map +1 -0
- package/dist/services/auth.d.ts +127 -0
- package/dist/services/auth.d.ts.map +1 -0
- package/dist/services/auth.js +562 -0
- package/dist/services/auth.js.map +1 -0
- package/dist/stores/auth.d.ts +174 -0
- package/dist/stores/auth.d.ts.map +1 -0
- package/dist/stores/auth.js +262 -0
- package/dist/stores/auth.js.map +1 -0
- package/dist/types/api.d.ts +52 -0
- package/dist/types/api.d.ts.map +1 -0
- package/dist/types/api.js +7 -0
- package/dist/types/api.js.map +1 -0
- package/dist/types/user.d.ts +42 -0
- package/dist/types/user.d.ts.map +1 -0
- package/dist/types/user.js +45 -0
- package/dist/types/user.js.map +1 -0
- package/dist/utils/tokenStorage.d.ts +41 -0
- package/dist/utils/tokenStorage.d.ts.map +1 -0
- package/dist/utils/tokenStorage.js +300 -0
- package/dist/utils/tokenStorage.js.map +1 -0
- package/package.json +40 -0
- package/src/composables/useAuth.ts +164 -0
- package/src/index.ts +118 -0
- package/src/plugins/terminology/index.ts +114 -0
- package/src/plugins/terminology/terms.ts +104 -0
- package/src/plugins/terminology/types.ts +28 -0
- package/src/services/api.ts +472 -0
- package/src/services/auth.ts +874 -0
- package/src/stores/auth.ts +400 -0
- package/src/types/api.ts +56 -0
- package/src/types/user.ts +94 -0
- package/src/utils/tokenStorage.ts +394 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurable auth services and Axios auth-client wiring for
|
|
3
|
+
* SocialKit-powered frontends.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
|
|
7
|
+
import type { TokenStorage } from '../utils/tokenStorage.js'
|
|
8
|
+
import { extractTokenFromResponse } from '../utils/tokenStorage.js'
|
|
9
|
+
import type { User } from '../types/user.js'
|
|
10
|
+
|
|
11
|
+
/** Configuration for creating an auth service. */
|
|
12
|
+
export interface AuthServiceConfig {
|
|
13
|
+
/** Base URL for API requests (e.g. '/api' or 'https://api.example.com/api'). */
|
|
14
|
+
baseURL: string
|
|
15
|
+
/** Token storage instance for persisting auth tokens. */
|
|
16
|
+
tokenStorage: TokenStorage
|
|
17
|
+
/** Request timeout in milliseconds (default: 30000). */
|
|
18
|
+
timeout?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ExtendedAuthServiceConfig extends AuthServiceConfig {
|
|
22
|
+
/** Optional existing Axios client to use instead of creating a dedicated one. */
|
|
23
|
+
client?: AxiosInstance
|
|
24
|
+
/** Storage key for the client device identifier used by login/2FA endpoints. */
|
|
25
|
+
deviceIdStorageKey?: string
|
|
26
|
+
/** Header name used for the device identifier (default: 'X-Device-Id'). */
|
|
27
|
+
deviceIdHeaderName?: string
|
|
28
|
+
/** How forgot-password payloads should be derived from user input. */
|
|
29
|
+
forgotPasswordIdentifierStrategy?: 'email-only' | 'email-or-username'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Registration data shape. */
|
|
33
|
+
export interface RegisterData {
|
|
34
|
+
handle: string
|
|
35
|
+
name: string
|
|
36
|
+
email: string
|
|
37
|
+
password: string
|
|
38
|
+
password_confirmation: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Password reset data shape. */
|
|
42
|
+
export interface ResetPasswordData {
|
|
43
|
+
token: string
|
|
44
|
+
email: string
|
|
45
|
+
password: string
|
|
46
|
+
password_confirmation: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Login response shape (user + optional token). */
|
|
50
|
+
export interface LoginResponse {
|
|
51
|
+
user: User
|
|
52
|
+
token?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AuthenticatedLoginResponse extends LoginResponse {
|
|
56
|
+
requires_2fa?: false
|
|
57
|
+
challenge_id?: never
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TwoFactorChallengeResponse {
|
|
61
|
+
requires_2fa: true
|
|
62
|
+
challenge_id: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AuthenticatedRegisterResponse extends LoginResponse {
|
|
66
|
+
requires_verification: false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface VerificationRequiredRegisterResponse {
|
|
70
|
+
requires_verification: true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type AuthLoginResult = AuthenticatedLoginResponse | TwoFactorChallengeResponse
|
|
74
|
+
export type RegisterResponse = AuthenticatedRegisterResponse | VerificationRequiredRegisterResponse
|
|
75
|
+
|
|
76
|
+
/** Public auth service interface. */
|
|
77
|
+
export interface AuthServiceInstance {
|
|
78
|
+
login(loginCredential: string, password: string, remember?: boolean): Promise<LoginResponse>
|
|
79
|
+
register(data: RegisterData): Promise<LoginResponse>
|
|
80
|
+
logout(): Promise<void>
|
|
81
|
+
getUser(): Promise<User>
|
|
82
|
+
forgotPassword(email: string): Promise<unknown>
|
|
83
|
+
resetPassword(data: ResetPasswordData): Promise<unknown>
|
|
84
|
+
hasToken(): boolean
|
|
85
|
+
clearToken(): void
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ExtendedAuthServiceInstance {
|
|
89
|
+
login(loginCredential: string, password: string, remember?: boolean): Promise<AuthLoginResult>
|
|
90
|
+
register(data: RegisterData): Promise<RegisterResponse>
|
|
91
|
+
verify2faChallenge(
|
|
92
|
+
challengeId: string,
|
|
93
|
+
code?: string,
|
|
94
|
+
recoveryCode?: string,
|
|
95
|
+
rememberDevice?: boolean,
|
|
96
|
+
rememberSession?: boolean
|
|
97
|
+
): Promise<AuthenticatedLoginResponse>
|
|
98
|
+
logout(): Promise<void>
|
|
99
|
+
getUser(): Promise<User>
|
|
100
|
+
forgotPassword(identifier: string): Promise<unknown>
|
|
101
|
+
resetPassword(data: ResetPasswordData): Promise<unknown>
|
|
102
|
+
hasToken(): boolean
|
|
103
|
+
clearToken(): void
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface RetriableRequestConfig extends AxiosRequestConfig {
|
|
107
|
+
_retry?: boolean
|
|
108
|
+
_policyRetry?: boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface MissingPolicy {
|
|
112
|
+
policy_id: string
|
|
113
|
+
version: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface TokenAuthClientConfig {
|
|
117
|
+
/** Existing Axios client to configure. Defaults to the shared axios export. */
|
|
118
|
+
client?: AxiosInstance
|
|
119
|
+
/** Base URL for API requests. */
|
|
120
|
+
baseURL: string
|
|
121
|
+
/** Token storage instance providing auth state and refresh behavior. */
|
|
122
|
+
tokenStorage: TokenStorage
|
|
123
|
+
/** Request timeout in milliseconds (default: 30000). */
|
|
124
|
+
timeout?: number
|
|
125
|
+
/** Delay before queued auth teardown/redirect executes (default: 1500). */
|
|
126
|
+
authFailureRedirectDelayMs?: number
|
|
127
|
+
/** Paths considered auth pages; queued redirects will not re-target them. */
|
|
128
|
+
authPagePaths?: string[]
|
|
129
|
+
/** Build the redirect target when auth teardown occurs. */
|
|
130
|
+
buildLoginRedirectUrl?: () => string
|
|
131
|
+
/** Called when auth teardown actually executes. */
|
|
132
|
+
onAuthInvalidated?: () => void
|
|
133
|
+
/** Called for response errors that should surface globally. */
|
|
134
|
+
onResponseError?: (error: unknown) => void
|
|
135
|
+
/** Suppress global error reporting for specific responses. */
|
|
136
|
+
shouldSuppressResponseError?: (error: unknown) => boolean
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface ConfiguredTokenAuthClient {
|
|
140
|
+
client: AxiosInstance
|
|
141
|
+
setValidatingAuth(value: boolean): void
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const DEFAULT_DEVICE_ID_STORAGE_KEY = 'socialkit_device_id'
|
|
145
|
+
const DEFAULT_DEVICE_ID_HEADER_NAME = 'X-Device-Id'
|
|
146
|
+
const DEFAULT_AUTH_FAILURE_REDIRECT_DELAY_MS = 1500
|
|
147
|
+
const DEFAULT_AUTH_PAGE_PATHS = ['/login', '/register']
|
|
148
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
149
|
+
|
|
150
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
151
|
+
return typeof value === 'object' && value !== null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function deriveRole(roles: string[]): 'admin' | 'moderator' | 'user' {
|
|
155
|
+
if (roles.includes('admin') || roles.includes('super-admin')) {
|
|
156
|
+
return 'admin'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (roles.includes('moderator')) {
|
|
160
|
+
return 'moderator'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return 'user'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeUserPayload(raw: Record<string, unknown>): User {
|
|
167
|
+
const rawRoles = raw.roles
|
|
168
|
+
const roles = Array.isArray(rawRoles)
|
|
169
|
+
? rawRoles.filter((value): value is string => typeof value === 'string')
|
|
170
|
+
: []
|
|
171
|
+
|
|
172
|
+
const id = typeof raw.id === 'string' ? raw.id : ''
|
|
173
|
+
const name = typeof raw.name === 'string' ? raw.name : ''
|
|
174
|
+
const email = typeof raw.email === 'string' ? raw.email : ''
|
|
175
|
+
const avatarValue = isRecord(raw.avatar) ? raw.avatar : null
|
|
176
|
+
const avatarUrl = typeof avatarValue?.url === 'string' ? avatarValue.url : null
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
...raw,
|
|
180
|
+
id,
|
|
181
|
+
name,
|
|
182
|
+
email,
|
|
183
|
+
role: deriveRole(roles),
|
|
184
|
+
avatar: avatarValue,
|
|
185
|
+
avatar_url: avatarUrl
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function createConfiguredClient(baseURL: string, timeout: number, client?: AxiosInstance): AxiosInstance {
|
|
190
|
+
if (client) {
|
|
191
|
+
client.defaults.baseURL = baseURL
|
|
192
|
+
client.defaults.timeout = timeout
|
|
193
|
+
client.defaults.headers.common.Accept = 'application/json'
|
|
194
|
+
client.defaults.headers.common['Content-Type'] = 'application/json'
|
|
195
|
+
client.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
|
|
196
|
+
client.defaults.withCredentials = false
|
|
197
|
+
return client
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return axios.create({
|
|
201
|
+
baseURL,
|
|
202
|
+
timeout,
|
|
203
|
+
headers: {
|
|
204
|
+
Accept: 'application/json',
|
|
205
|
+
'Content-Type': 'application/json',
|
|
206
|
+
'X-Requested-With': 'XMLHttpRequest'
|
|
207
|
+
},
|
|
208
|
+
withCredentials: false
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isUuid(value: string): boolean {
|
|
213
|
+
return UUID_PATTERN.test(value)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function generateDeviceIdWithCrypto(): string {
|
|
217
|
+
const bytes = new Uint8Array(16)
|
|
218
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
219
|
+
|
|
220
|
+
const byteSix = bytes[6] ?? 0
|
|
221
|
+
const byteEight = bytes[8] ?? 0
|
|
222
|
+
bytes[6] = (byteSix & 0x0f) | 0x40
|
|
223
|
+
bytes[8] = (byteEight & 0x3f) | 0x80
|
|
224
|
+
|
|
225
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
226
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function generateDeviceId(): string {
|
|
230
|
+
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
|
231
|
+
return globalThis.crypto.randomUUID().toLowerCase()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (typeof globalThis.crypto?.getRandomValues === 'function') {
|
|
235
|
+
return generateDeviceIdWithCrypto()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return `${Date.now().toString(16).padStart(12, '0')}-fallback-device-id`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function createDeviceIdentifierHeadersProvider(
|
|
242
|
+
storageKey = DEFAULT_DEVICE_ID_STORAGE_KEY,
|
|
243
|
+
headerName = DEFAULT_DEVICE_ID_HEADER_NAME
|
|
244
|
+
): () => Record<string, string> {
|
|
245
|
+
let cachedDeviceId: string | null = null
|
|
246
|
+
|
|
247
|
+
function getStoredDeviceId(): string | null {
|
|
248
|
+
try {
|
|
249
|
+
const storedValue = localStorage.getItem(storageKey)
|
|
250
|
+
if (storedValue && isUuid(storedValue)) {
|
|
251
|
+
return storedValue.toLowerCase()
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function persistDeviceId(deviceId: string): void {
|
|
261
|
+
try {
|
|
262
|
+
localStorage.setItem(storageKey, deviceId)
|
|
263
|
+
} catch {
|
|
264
|
+
// Ignore storage failures; request-level usage still works for this session.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getOrCreateDeviceId(): string {
|
|
269
|
+
if (cachedDeviceId && isUuid(cachedDeviceId)) {
|
|
270
|
+
return cachedDeviceId
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const storedDeviceId = getStoredDeviceId()
|
|
274
|
+
if (storedDeviceId) {
|
|
275
|
+
cachedDeviceId = storedDeviceId
|
|
276
|
+
return storedDeviceId
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const generatedDeviceId = generateDeviceId()
|
|
280
|
+
if (!isUuid(generatedDeviceId)) {
|
|
281
|
+
return ''
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const normalizedDeviceId = generatedDeviceId.toLowerCase()
|
|
285
|
+
cachedDeviceId = normalizedDeviceId
|
|
286
|
+
persistDeviceId(normalizedDeviceId)
|
|
287
|
+
return normalizedDeviceId
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return (): Record<string, string> => {
|
|
291
|
+
const deviceId = getOrCreateDeviceId()
|
|
292
|
+
if (deviceId === '') {
|
|
293
|
+
return {}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
[headerName]: deviceId
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildForgotPasswordPayload(
|
|
303
|
+
identifier: string,
|
|
304
|
+
strategy: 'email-only' | 'email-or-username'
|
|
305
|
+
): Record<string, string> {
|
|
306
|
+
if (strategy === 'email-only') {
|
|
307
|
+
return { email: identifier.trim() }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const trimmedIdentifier = identifier.trim()
|
|
311
|
+
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
312
|
+
|
|
313
|
+
if (emailPattern.test(trimmedIdentifier)) {
|
|
314
|
+
return { email: trimmedIdentifier }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { username: trimmedIdentifier.replace(/^@+/, '') }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function withAuthorizationHeader(config: RetriableRequestConfig, token: string): RetriableRequestConfig {
|
|
321
|
+
return {
|
|
322
|
+
...config,
|
|
323
|
+
headers: {
|
|
324
|
+
...(config.headers as Record<string, string> | undefined),
|
|
325
|
+
Authorization: `Bearer ${token}`
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function parseMissingPolicies(payload: unknown): MissingPolicy[] {
|
|
331
|
+
if (!isRecord(payload)) {
|
|
332
|
+
return []
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const errors = payload.errors
|
|
336
|
+
if (!isRecord(errors)) {
|
|
337
|
+
return []
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const missing = errors.missing
|
|
341
|
+
if (!Array.isArray(missing)) {
|
|
342
|
+
return []
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const parsed: MissingPolicy[] = []
|
|
346
|
+
|
|
347
|
+
for (const item of missing) {
|
|
348
|
+
if (!isRecord(item)) {
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const policyId = item.policy_id
|
|
353
|
+
const version = item.version
|
|
354
|
+
if (typeof policyId !== 'string') {
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const parsedVersion = typeof version === 'number'
|
|
359
|
+
? version
|
|
360
|
+
: typeof version === 'string'
|
|
361
|
+
? Number.parseInt(version, 10)
|
|
362
|
+
: Number.NaN
|
|
363
|
+
|
|
364
|
+
if (!Number.isInteger(parsedVersion)) {
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
parsed.push({
|
|
369
|
+
policy_id: policyId,
|
|
370
|
+
version: parsedVersion
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return parsed
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function dedupeMissingPolicies(policies: MissingPolicy[]): MissingPolicy[] {
|
|
378
|
+
const deduped = new Map<string, MissingPolicy>()
|
|
379
|
+
|
|
380
|
+
for (const policy of policies) {
|
|
381
|
+
const existing = deduped.get(policy.policy_id)
|
|
382
|
+
if (!existing || policy.version > existing.version) {
|
|
383
|
+
deduped.set(policy.policy_id, policy)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return [...deduped.values()]
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function isPoliciesPendingErrorPayload(payload: unknown): boolean {
|
|
391
|
+
return isRecord(payload) && payload.code === 'POLICIES_PENDING'
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function isPolicyAcceptRequest(url: unknown): boolean {
|
|
395
|
+
return typeof url === 'string' && url.includes('/v1/policy/accept')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Create a configured auth service instance.
|
|
400
|
+
*
|
|
401
|
+
* This is the conservative base service used by shared auth-store code.
|
|
402
|
+
*/
|
|
403
|
+
export function createAuthService(config: AuthServiceConfig): AuthServiceInstance {
|
|
404
|
+
const {
|
|
405
|
+
baseURL,
|
|
406
|
+
tokenStorage,
|
|
407
|
+
timeout = 30000
|
|
408
|
+
} = config
|
|
409
|
+
|
|
410
|
+
const client = createConfiguredClient(baseURL, timeout)
|
|
411
|
+
|
|
412
|
+
client.interceptors.request.use((reqConfig) => {
|
|
413
|
+
const url = reqConfig.url ?? ''
|
|
414
|
+
|
|
415
|
+
if (tokenStorage.shouldSkipAuth(url)) {
|
|
416
|
+
return reqConfig
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const token = tokenStorage.getToken()
|
|
420
|
+
if (token) {
|
|
421
|
+
reqConfig.headers.Authorization = `Bearer ${token}`
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return reqConfig
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
async login(loginCredential: string, password: string, remember = false): Promise<LoginResponse> {
|
|
429
|
+
const response = await client.post('/v1/auth/login', {
|
|
430
|
+
login: loginCredential,
|
|
431
|
+
password,
|
|
432
|
+
remember
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const responseData = response.data
|
|
436
|
+
const token = extractTokenFromResponse(responseData)
|
|
437
|
+
|
|
438
|
+
if (token) {
|
|
439
|
+
tokenStorage.setToken(token)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return responseData.data ?? responseData
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
async register(data: RegisterData): Promise<LoginResponse> {
|
|
446
|
+
const response = await client.post('/v1/auth/register', data)
|
|
447
|
+
|
|
448
|
+
const responseData = response.data
|
|
449
|
+
const token = extractTokenFromResponse(responseData)
|
|
450
|
+
|
|
451
|
+
if (token) {
|
|
452
|
+
tokenStorage.setToken(token)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return responseData.data ?? responseData
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
async logout(): Promise<void> {
|
|
459
|
+
try {
|
|
460
|
+
await client.post('/v1/auth/logout')
|
|
461
|
+
} catch {
|
|
462
|
+
// Even if the API call fails, we clear local state.
|
|
463
|
+
} finally {
|
|
464
|
+
tokenStorage.removeToken()
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
async getUser(): Promise<User> {
|
|
469
|
+
const response = await client.get('/v1/me', {
|
|
470
|
+
params: { _t: Date.now() },
|
|
471
|
+
headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
const rawPayload = isRecord(response.data?.data)
|
|
475
|
+
? response.data.data
|
|
476
|
+
: isRecord(response.data)
|
|
477
|
+
? response.data
|
|
478
|
+
: {}
|
|
479
|
+
|
|
480
|
+
return normalizeUserPayload(rawPayload)
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
async forgotPassword(email: string): Promise<unknown> {
|
|
484
|
+
const response = await client.post('/v1/auth/forgot-password', { email })
|
|
485
|
+
return response.data
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
async resetPassword(data: ResetPasswordData): Promise<unknown> {
|
|
489
|
+
const response = await client.post('/v1/auth/reset-password', data)
|
|
490
|
+
|
|
491
|
+
const token = extractTokenFromResponse(response.data)
|
|
492
|
+
if (token) {
|
|
493
|
+
tokenStorage.setToken(token)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return response.data
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
hasToken(): boolean {
|
|
500
|
+
return tokenStorage.hasToken()
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
clearToken(): void {
|
|
504
|
+
tokenStorage.removeToken()
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Create a richer auth workflow service for apps that use 2FA and device-id
|
|
511
|
+
* headers during login/verification flows.
|
|
512
|
+
*/
|
|
513
|
+
export function createExtendedAuthService(config: ExtendedAuthServiceConfig): ExtendedAuthServiceInstance {
|
|
514
|
+
const {
|
|
515
|
+
baseURL,
|
|
516
|
+
tokenStorage,
|
|
517
|
+
timeout = 30000,
|
|
518
|
+
client,
|
|
519
|
+
deviceIdStorageKey = DEFAULT_DEVICE_ID_STORAGE_KEY,
|
|
520
|
+
deviceIdHeaderName = DEFAULT_DEVICE_ID_HEADER_NAME,
|
|
521
|
+
forgotPasswordIdentifierStrategy = 'email-or-username'
|
|
522
|
+
} = config
|
|
523
|
+
|
|
524
|
+
const authClient = createConfiguredClient(baseURL, timeout, client)
|
|
525
|
+
const getDeviceIdentifierHeaders = createDeviceIdentifierHeadersProvider(
|
|
526
|
+
deviceIdStorageKey,
|
|
527
|
+
deviceIdHeaderName
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
async login(loginCredential: string, password: string, remember = false): Promise<AuthLoginResult> {
|
|
532
|
+
const response = await authClient.post('/v1/auth/login', {
|
|
533
|
+
login: loginCredential,
|
|
534
|
+
password,
|
|
535
|
+
remember
|
|
536
|
+
}, {
|
|
537
|
+
headers: getDeviceIdentifierHeaders()
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
const responseData = response.data
|
|
541
|
+
const inner = responseData.data ?? responseData
|
|
542
|
+
|
|
543
|
+
if (inner.requires_2fa === true && typeof inner.challenge_id === 'string') {
|
|
544
|
+
return {
|
|
545
|
+
requires_2fa: true,
|
|
546
|
+
challenge_id: inner.challenge_id
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const token = extractTokenFromResponse(responseData)
|
|
551
|
+
if (token) {
|
|
552
|
+
tokenStorage.setToken(token)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return inner as AuthenticatedLoginResponse
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
async register(data: RegisterData): Promise<RegisterResponse> {
|
|
559
|
+
const response = await authClient.post('/v1/auth/register', data)
|
|
560
|
+
const responseData = response.data
|
|
561
|
+
const token = extractTokenFromResponse(responseData)
|
|
562
|
+
|
|
563
|
+
if (token) {
|
|
564
|
+
tokenStorage.setToken(token)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const result = responseData.data ?? responseData
|
|
568
|
+
|
|
569
|
+
if (!token) {
|
|
570
|
+
return {
|
|
571
|
+
requires_verification: true
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
...result,
|
|
577
|
+
requires_verification: false
|
|
578
|
+
} as AuthenticatedRegisterResponse
|
|
579
|
+
},
|
|
580
|
+
|
|
581
|
+
async verify2faChallenge(
|
|
582
|
+
challengeId: string,
|
|
583
|
+
code?: string,
|
|
584
|
+
recoveryCode?: string,
|
|
585
|
+
rememberDevice = false,
|
|
586
|
+
rememberSession = false
|
|
587
|
+
): Promise<AuthenticatedLoginResponse> {
|
|
588
|
+
const payload: Record<string, unknown> = {
|
|
589
|
+
challenge_id: challengeId,
|
|
590
|
+
remember_session: rememberSession
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (code) {
|
|
594
|
+
payload.code = code
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (recoveryCode) {
|
|
598
|
+
payload.recovery_code = recoveryCode
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (rememberDevice) {
|
|
602
|
+
payload.remember_device = true
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const response = await authClient.post('/v1/auth/2fa/verify', payload, {
|
|
606
|
+
headers: getDeviceIdentifierHeaders()
|
|
607
|
+
})
|
|
608
|
+
const responseData = response.data
|
|
609
|
+
const token = extractTokenFromResponse(responseData)
|
|
610
|
+
|
|
611
|
+
if (token) {
|
|
612
|
+
tokenStorage.setToken(token)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return (responseData.data ?? responseData) as AuthenticatedLoginResponse
|
|
616
|
+
},
|
|
617
|
+
|
|
618
|
+
async logout(): Promise<void> {
|
|
619
|
+
try {
|
|
620
|
+
await authClient.post('/v1/auth/logout')
|
|
621
|
+
} catch {
|
|
622
|
+
// Even if the API call fails, we clear local state.
|
|
623
|
+
} finally {
|
|
624
|
+
tokenStorage.removeToken()
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
async getUser(): Promise<User> {
|
|
629
|
+
const response = await authClient.get('/v1/me', {
|
|
630
|
+
params: { _t: Date.now() },
|
|
631
|
+
headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
const rawPayload = isRecord(response.data?.data)
|
|
635
|
+
? response.data.data
|
|
636
|
+
: isRecord(response.data)
|
|
637
|
+
? response.data
|
|
638
|
+
: {}
|
|
639
|
+
|
|
640
|
+
return normalizeUserPayload(rawPayload)
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
async forgotPassword(identifier: string): Promise<unknown> {
|
|
644
|
+
const response = await authClient.post(
|
|
645
|
+
'/v1/auth/forgot-password',
|
|
646
|
+
buildForgotPasswordPayload(identifier, forgotPasswordIdentifierStrategy)
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
return response.data
|
|
650
|
+
},
|
|
651
|
+
|
|
652
|
+
async resetPassword(data: ResetPasswordData): Promise<unknown> {
|
|
653
|
+
const response = await authClient.post('/v1/auth/reset-password', data)
|
|
654
|
+
|
|
655
|
+
const token = extractTokenFromResponse(response.data)
|
|
656
|
+
if (token) {
|
|
657
|
+
tokenStorage.setToken(token)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return response.data
|
|
661
|
+
},
|
|
662
|
+
|
|
663
|
+
hasToken(): boolean {
|
|
664
|
+
return tokenStorage.hasToken()
|
|
665
|
+
},
|
|
666
|
+
|
|
667
|
+
clearToken(): void {
|
|
668
|
+
tokenStorage.removeToken()
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Configure a shared Axios client with generic token-auth request/response
|
|
675
|
+
* interceptors while preserving the raw Axios surface for host apps.
|
|
676
|
+
*/
|
|
677
|
+
export function configureTokenAuthClient(config: TokenAuthClientConfig): ConfiguredTokenAuthClient {
|
|
678
|
+
const {
|
|
679
|
+
client = axios,
|
|
680
|
+
baseURL,
|
|
681
|
+
tokenStorage,
|
|
682
|
+
timeout = 30000,
|
|
683
|
+
authFailureRedirectDelayMs = DEFAULT_AUTH_FAILURE_REDIRECT_DELAY_MS,
|
|
684
|
+
authPagePaths = DEFAULT_AUTH_PAGE_PATHS,
|
|
685
|
+
buildLoginRedirectUrl,
|
|
686
|
+
onAuthInvalidated,
|
|
687
|
+
onResponseError,
|
|
688
|
+
shouldSuppressResponseError
|
|
689
|
+
} = config
|
|
690
|
+
|
|
691
|
+
client.defaults.baseURL = baseURL
|
|
692
|
+
client.defaults.timeout = timeout
|
|
693
|
+
client.defaults.headers.common.Accept = 'application/json'
|
|
694
|
+
client.defaults.headers.common['Content-Type'] = 'application/json'
|
|
695
|
+
client.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
|
|
696
|
+
client.defaults.withCredentials = false
|
|
697
|
+
|
|
698
|
+
let isValidatingAuth = false
|
|
699
|
+
let authFailureRedirectTimeoutId: number | null = null
|
|
700
|
+
let authFailureQueuedToken: string | null = null
|
|
701
|
+
let policyAcceptancePromise: Promise<boolean> | null = null
|
|
702
|
+
|
|
703
|
+
function cancelQueuedAuthFailureRedirect(): void {
|
|
704
|
+
if (authFailureRedirectTimeoutId === null) {
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
window.clearTimeout(authFailureRedirectTimeoutId)
|
|
709
|
+
authFailureRedirectTimeoutId = null
|
|
710
|
+
authFailureQueuedToken = null
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function queueAuthFailureRedirect(): void {
|
|
714
|
+
if (authFailureRedirectTimeoutId !== null) {
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
authFailureQueuedToken = tokenStorage.getToken()
|
|
719
|
+
|
|
720
|
+
authFailureRedirectTimeoutId = window.setTimeout(() => {
|
|
721
|
+
authFailureRedirectTimeoutId = null
|
|
722
|
+
const queuedToken = authFailureQueuedToken
|
|
723
|
+
authFailureQueuedToken = null
|
|
724
|
+
const currentToken = tokenStorage.getToken()
|
|
725
|
+
|
|
726
|
+
if (
|
|
727
|
+
queuedToken !== null &&
|
|
728
|
+
currentToken !== null &&
|
|
729
|
+
currentToken !== queuedToken
|
|
730
|
+
) {
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
onAuthInvalidated?.()
|
|
735
|
+
tokenStorage.removeToken()
|
|
736
|
+
|
|
737
|
+
const currentPath = window.location.pathname
|
|
738
|
+
if (authPagePaths.includes(currentPath)) {
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const redirectUrl = buildLoginRedirectUrl?.() ?? '/login'
|
|
743
|
+
window.location.assign(redirectUrl)
|
|
744
|
+
}, authFailureRedirectDelayMs)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function acceptMissingPolicies(missingPolicies: MissingPolicy[]): Promise<boolean> {
|
|
748
|
+
const policiesToAccept = dedupeMissingPolicies(missingPolicies)
|
|
749
|
+
if (policiesToAccept.length === 0) {
|
|
750
|
+
return false
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (policyAcceptancePromise) {
|
|
754
|
+
return policyAcceptancePromise
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
policyAcceptancePromise = (async () => {
|
|
758
|
+
try {
|
|
759
|
+
for (const policy of policiesToAccept) {
|
|
760
|
+
await client.post('/v1/policy/accept', {
|
|
761
|
+
policy_id: policy.policy_id,
|
|
762
|
+
version: policy.version
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return true
|
|
767
|
+
} catch {
|
|
768
|
+
return false
|
|
769
|
+
} finally {
|
|
770
|
+
policyAcceptancePromise = null
|
|
771
|
+
}
|
|
772
|
+
})()
|
|
773
|
+
|
|
774
|
+
return policyAcceptancePromise
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
client.interceptors.request.use((reqConfig) => {
|
|
778
|
+
const url = reqConfig.url ?? ''
|
|
779
|
+
|
|
780
|
+
if (tokenStorage.shouldSkipAuth(url)) {
|
|
781
|
+
return reqConfig
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const token = tokenStorage.getToken()
|
|
785
|
+
if (token) {
|
|
786
|
+
reqConfig.headers.Authorization = `Bearer ${token}`
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (typeof FormData !== 'undefined' && reqConfig.data instanceof FormData) {
|
|
790
|
+
delete reqConfig.headers['Content-Type']
|
|
791
|
+
delete reqConfig.headers['content-type']
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return reqConfig
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
client.interceptors.response.use(
|
|
798
|
+
(response) => {
|
|
799
|
+
cancelQueuedAuthFailureRedirect()
|
|
800
|
+
return response
|
|
801
|
+
},
|
|
802
|
+
async (error) => {
|
|
803
|
+
if (error.response?.status === 428) {
|
|
804
|
+
const originalRequest = error.config as RetriableRequestConfig | undefined
|
|
805
|
+
const requestUrl = originalRequest?.url
|
|
806
|
+
const responsePayload = error.response?.data
|
|
807
|
+
const missingPolicies = parseMissingPolicies(responsePayload)
|
|
808
|
+
const canAttemptPolicyAcceptance = Boolean(
|
|
809
|
+
originalRequest &&
|
|
810
|
+
!originalRequest._policyRetry &&
|
|
811
|
+
!isPolicyAcceptRequest(requestUrl) &&
|
|
812
|
+
isPoliciesPendingErrorPayload(responsePayload) &&
|
|
813
|
+
missingPolicies.length > 0
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
if (canAttemptPolicyAcceptance && originalRequest) {
|
|
817
|
+
originalRequest._policyRetry = true
|
|
818
|
+
const accepted = await acceptMissingPolicies(missingPolicies)
|
|
819
|
+
|
|
820
|
+
if (accepted) {
|
|
821
|
+
return client(originalRequest)
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (error.response?.status === 401) {
|
|
827
|
+
const originalRequest = error.config as RetriableRequestConfig | undefined
|
|
828
|
+
const requestUrl = originalRequest?.url
|
|
829
|
+
const canAttemptRefresh = Boolean(
|
|
830
|
+
originalRequest &&
|
|
831
|
+
!originalRequest._retry &&
|
|
832
|
+
!tokenStorage.shouldSkipAuth(requestUrl) &&
|
|
833
|
+
!tokenStorage.isRefreshTokenEndpoint(requestUrl)
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
if (canAttemptRefresh && originalRequest) {
|
|
837
|
+
originalRequest._retry = true
|
|
838
|
+
const refreshedToken = await tokenStorage.tryRefreshToken()
|
|
839
|
+
|
|
840
|
+
if (refreshedToken) {
|
|
841
|
+
cancelQueuedAuthFailureRedirect()
|
|
842
|
+
const retryConfig = withAuthorizationHeader(originalRequest, refreshedToken)
|
|
843
|
+
return client(retryConfig)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (tokenStorage.wasTokenRotatedSince(originalRequest?.headers)) {
|
|
848
|
+
if (!shouldSuppressResponseError?.(error)) {
|
|
849
|
+
onResponseError?.(error)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return Promise.reject(error)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (!isValidatingAuth) {
|
|
856
|
+
queueAuthFailureRedirect()
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (!shouldSuppressResponseError?.(error)) {
|
|
861
|
+
onResponseError?.(error)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return Promise.reject(error)
|
|
865
|
+
}
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
return {
|
|
869
|
+
client,
|
|
870
|
+
setValidatingAuth(value: boolean): void {
|
|
871
|
+
isValidatingAuth = value
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|