import Axios from 'axios'
/**
* @typedef {Object} identityApplyOptions
* @property {String} [type='query'] Type d'application (query, header, headers, bearer, basic, cookie)
* @property {String} [name] Nom de la clé (header, cookie, query)
* @property {String} [scheme] Schéma du header d'autorisation (ex: Bearer)
* @property {String} [username] Identifiant pour basic auth (en clair, peu recommandé)
* @property {String} [password] Mot de passe pour basic auth (en clair, peu recommandé)
* @property {Boolean} [encoded=false] Indique si la valeur basic est déjà encodée en base64
*/
/**
* paramètre de type tokenOption
* @typedef {Object} identityOptions
* @property {String} type Type de token : TOKEN (token en clair), IDENTITY (info d'identité en clair), LOCALSTORAGE (identité stockée dans le localstorage), URL (identity demandée a un tiers de confiance), FUNCTION (identité demandée a une fonction)
* @property {Number} [refreshInterval] Intervalle de rafraichissement du token en ms (optionnel)
* @property {String} [token] Le token (si type TOKEN)
* @property {String | Object} [identity] L'identité (si type IDENTITY)
* @property {String} [path] Le path en localstorage (si type LOCALSTORAGE)
* @property {String} [url] L'url à appeler pour récupérer le token (si type URL)
* @property {String} [method] La méthode d'appel (GET, POST, PUT, DELETE) (si type URL) (défaut POST)
* @property {Object} [data] Les données à envoyer (si type URL)
* @property {Object} [headers] Les headers à envoyer (si type URL)
* @property {Object} [auth] Les informations d'authentification {username, password} à envoyer (si type URL)
* @property {String} [responseIdentityName] Le nom de la propriété dans la réponse qui contient le token (si type URL ou localstorage)
* @property {Function} [getIdentity] La fonction à appeler pour récupérer l'identité (si type FUNCTION)
* @property {identityApplyOptions} [apply] Stratégie d'application du token/identité dans la Request
* @example
* // Header Bearer
* {
* type: 'TOKEN',
* token: 'abc123',
* apply: { type: 'bearer' }
* }
* @example
* // Multi headers
* {
* type: 'IDENTITY',
* identity: {
* Authorization: 'Bearer abc123',
* 'X-Api-Key': 'my-key'
* },
* apply: { type: 'headers' }
* }
*/
export default class IdentityManager {
/**
* @ type {identityOptions | String}
*/
#options
/**
* @ type {String | Object | null } valeur en fonction du type d'application (string ou object)
*/
#identityValue
/**
* @ type {Number | null} Timestamp de la dernière récupération d'identité
*/
#lastFetchTime = null
/**
* @ type {Promise | null} Promise de récupération en cours (évite les race conditions)
*/
#fetchPromise = null
get identity () {
return this.#identityValue
}
/**
* @param {identityOptions | String} options Options de gestion du token ou token en clair (historique)
*/
constructor (options) {
this.#options = options
}
/**
* Vérifie si l'identité a expiré et doit être rafraîchie
* @returns {boolean}
*/
#isIdentityExpired () {
// Pas encore récupérée
if (this.#lastFetchTime === null) {
return true
}
// Pas de refreshInterval configuré = pas d'expiration
if (!this.#options?.refreshInterval) {
return false
}
// Vérifier si le temps écoulé dépasse l'intervalle
const elapsed = Date.now() - this.#lastFetchTime
return elapsed > this.#options.refreshInterval
}
/**
* Récupère ou rafraîchit l'identité si nécessaire
* Gère les appels concurrents via cache de Promise
*/
async ensureIdentity () {
if (!this.#isIdentityExpired()) {
return // Identité valide, rien à faire
}
// Si un fetch est déjà en cours, attendre qu'il se termine
if (this.#fetchPromise) {
await this.#fetchPromise
return
}
// Lancer le fetch et cacher la Promise
this.#fetchPromise = this.#parseIdentityOptions()
.finally(() => {
// Nettoyer la Promise après résolution (succès ou erreur)
this.#fetchPromise = null
})
await this.#fetchPromise
// Vérifier qu'on a bien récupéré une identité
if (!this.#identityValue && this.#options?.type) {
throw new Error(`Failed to retrieve identity for type ${this.#options.type}`)
}
}
/** parsing des options de token */
async #parseIdentityOptions () {
const applyOptions = this.#getApplyOptions()
const identityOptions = this.#options
// cas "historique", on a juste une chaine de caractère
if (identityOptions === null || typeof identityOptions === 'string') {
this.#identityValue = identityOptions
return
}
if (typeof identityOptions !== 'object' || !identityOptions.type) {
console.error('tokenOptions doit être une chaine de caractère ou un objet avec une propriété "type"')
return
}
switch (identityOptions.type.toUpperCase()) {
// le token est dans une chaine de caractère
case 'TOKEN':
this.#identityValue = identityOptions.token
break
case 'IDENTITY':
this.#identityValue = identityOptions.identity
break
case 'LOCALSTORAGE':
{ // on essaye de lire l'identité en localstorage
let identityFromLocalStorage = localStorage.getItem(identityOptions.path)
if (identityFromLocalStorage !== null && identityFromLocalStorage !== '') {
try {
identityFromLocalStorage = JSON.parse(identityFromLocalStorage)
this.#identityValue = identityOptions.responseIdentityName
? identityFromLocalStorage[identityOptions.responseIdentityName]
: ['QUERY', 'COOKIE'].includes(applyOptions.type)
? identityFromLocalStorage.token
: identityFromLocalStorage
} catch (ex) {
console.error("erreur parsing de l'identité format `tokenOptions.responseIdentityName` attendu, recu : ", identityFromLocalStorage, ex)
}
}
}
break
case 'URL':
// on appelles une URL avec des options pour récupérer les infos d'identité
{
const { url, method = 'POST', data, headers, responseIdentityName, auth } = identityOptions
const response = await Axios({
url,
auth,
method,
data,
headers,
})
if (response.status === 200) {
if (responseIdentityName) {
this.#identityValue = response.data[responseIdentityName]
} else {
this.#identityValue = response.data
}
} else {
console.error('erreur récupération de l\'identité', response)
}
}
break
case 'FUNCTION':
// on appelle une fonction pour récupérer le token
this.#identityValue = await identityOptions.getIdentity()
break
}
// Mettre à jour le timestamp après récupération réussie
this.#lastFetchTime = Date.now()
}
destroy () {
this.#identityValue = null
this.#lastFetchTime = null
this.#fetchPromise = null
}
pause () {
console.warn('IdentityManager.pause() est déprécié et n\'a plus d\'effet')
}
async restart () {
console.warn('IdentityManager.restart() est déprécié : getRequest() rafraîchit automatiquement l\'identité si nécessaire')
// Forcer un refresh au prochain appel
this.#lastFetchTime = null
}
/**
* Construit une Request en appliquant l'identité sur query/header/cookie
* @param {String} url
* @param {RequestInit} [init = {}]
* @returns {Promise<Request>}
*/
async getRequest (url, init = {}) {
// Assurer que l'identité est disponible et valide
await this.ensureIdentity()
const applyOptions = this.#getApplyOptions()
let requestUrl = url
const headers = new Headers(init.headers || {})
switch (applyOptions.type) {
case 'HEADERS':
if (this.#identityValue && typeof this.#identityValue === 'object') {
Object.entries(this.#identityValue).forEach(([headerName, headerValue]) => {
if (headerValue !== undefined && headerValue !== null) {
headers.set(headerName, String(headerValue))
}
})
}
break
case 'BEARER':
if (this.#identityValue) {
headers.set('Authorization', `Bearer ${this.#identityValue}`)
}
break
case 'HEADER':
if (this.#identityValue) {
const headerName = applyOptions.name || 'Authorization'
const headerValue = applyOptions.scheme ? `${applyOptions.scheme} ${this.#identityValue}` : this.#identityValue
headers.set(headerName, headerValue)
}
break
case 'BASIC':
{
const basicValue = this.#buildBasicAuthorizationValue(applyOptions)
if (basicValue) {
headers.set('Authorization', `Basic ${basicValue}`)
}
}
break
case 'COOKIE':
if (this.#identityValue) {
const cookieName = applyOptions.name || 'token'
const nextCookie = `${encodeURIComponent(cookieName)}=${encodeURIComponent(this.#identityValue)}`
const existingCookie = headers.get('Cookie') || headers.get('cookie')
headers.set('Cookie', existingCookie ? `${existingCookie}; ${nextCookie}` : nextCookie)
}
break
case 'QUERY':
default:
requestUrl = this.#appendValueToUrl(url, applyOptions.name || 'token', this.#identityValue)
break
}
return new Request(requestUrl, {
...init,
headers,
})
}
/**
* Parse une url pour y ajouter le token
* @param {String} url
* @returns {String} url avec token
*/
addTokenToUrl (url) {
if (this.identity) {
const applyOptions = this.#getApplyOptions()
const tokenKey = applyOptions.name || 'token'
url = this.#appendValueToUrl(url, tokenKey, this.#identityValue)
}
return url
}
#getApplyOptions () {
if (typeof this.#options !== 'object' || this.#options === null) {
return {
type: 'QUERY',
}
}
const apply = this.#options.apply || {}
return {
...apply,
type: (apply.type || 'query').toUpperCase(),
}
}
#appendValueToUrl (url, key, value) {
if (value === null || value === undefined || value === '') {
return url
}
const stringUrl = String(url)
const [baseUrl, hashPart] = stringUrl.split('#')
const separator = baseUrl.indexOf('?') === -1 ? '?' : '&'
const param = `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
return `${baseUrl}${separator}${param}${hashPart !== undefined ? `#${hashPart}` : ''}`
}
#buildBasicAuthorizationValue (applyOptions) {
const username = applyOptions.username
const password = applyOptions.password
if (username !== undefined && password !== undefined) {
return this.#encodeBase64(`${username}:${password}`)
}
if (!this.#identityValue) {
return null
}
if (typeof this.#identityValue === 'object') {
const identityUsername = this.#identityValue.username
const identityPassword = this.#identityValue.password
if (identityUsername !== undefined && identityPassword !== undefined) {
return this.#encodeBase64(`${identityUsername}:${identityPassword}`)
}
}
return applyOptions.encoded ? this.#identityValue : this.#encodeBase64(this.#identityValue)
}
#encodeBase64 (value) {
if (typeof btoa === 'function') {
// Use TextEncoder to convert the string to a UTF-8 byte array,
// then convert each byte to a character to satisfy btoa's Latin1 requirement.
const bytes = new TextEncoder().encode(value)
const binString = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
return btoa(binString)
}
console.error('Aucune méthode disponible pour encoder en base64')
return ''
}
}