const RESOURCES = { teams: 'https://teams.optovia.ru', exchange: 'https://exchange.optovia.ru', orders: 'https://orders.optovia.ru', kyc: 'https://kyc.optovia.ru', billing: 'https://billing.optovia.ru' } as const type ResourceKey = keyof typeof RESOURCES type ResourceUrl = typeof RESOURCES[ResourceKey] interface TokenInfo { token: string expiresAt: number } interface RefreshResponse { tokens: Partial> } // Buffer before expiry to trigger refresh (60 seconds) const EXPIRY_BUFFER_MS = 60 * 1000 /** * Composable for managing Logto access tokens and ID token. * Uses useState for SSR→client sync and auto-refreshes when tokens expire. */ export const useLogtoTokens = () => { const tokens = useState>>('logto-tokens', () => ({})) const idToken = useState('logto-id-token', () => null) const isRefreshing = ref(false) let refreshPromise: Promise | null = null /** * Get organization ID from Logto user (first organization) */ const getOrganizationId = (): string | undefined => { if (import.meta.server) { const nuxtApp = useNuxtApp() const context = nuxtApp.ssrContext?.event.context as { logtoUser?: { organizations?: string[] } logtoOrgId?: string } | undefined return context?.logtoOrgId || context?.logtoUser?.organizations?.[0] } const orgId = useState('logto-org-id', () => null) return orgId.value || undefined } /** * Initialize tokens on SSR - fetches all tokens from Logto client */ const initTokens = async () => { if (import.meta.server) { const nuxtApp = useNuxtApp() const client = nuxtApp.ssrContext?.event.context.logtoClient const organizationId = getOrganizationId() if (client) { const results: Partial> = {} await Promise.all( (Object.entries(RESOURCES) as [ResourceKey, string][]).map(async ([key, resource]) => { try { // Pass organizationId to get organization-scoped token const token = await client.getAccessToken(resource, organizationId) if (token) { results[key] = { token, expiresAt: decodeTokenExpiry(token) } } } catch { // Token not available } }) ) tokens.value = results // Also fetch ID token for SSR try { const token = await client.getIdToken() if (token) { idToken.value = token } } catch { // ID token not available } } } } /** * Decode JWT to get expiry timestamp */ function decodeTokenExpiry(token: string): number { try { const payload = token.split('.')[1] if (payload) { const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')) const decoded = JSON.parse(json) if (decoded.exp) { return decoded.exp * 1000 } } } catch { // ignore } return Date.now() + 3600 * 1000 } /** * Check if token is expired or about to expire */ const isTokenExpired = (tokenInfo: TokenInfo | undefined): boolean => { if (!tokenInfo) return true return tokenInfo.expiresAt <= Date.now() + EXPIRY_BUFFER_MS } /** * Refresh all tokens from server */ const refreshTokens = async (): Promise => { // Deduplicate concurrent refresh calls if (refreshPromise) { return refreshPromise } isRefreshing.value = true refreshPromise = (async () => { try { const response = await $fetch('/api/auth/refresh', { method: 'POST' }) tokens.value = response.tokens } finally { isRefreshing.value = false refreshPromise = null } })() return refreshPromise } /** * Get access token for a resource URL. * Auto-refreshes if token is expired. */ const getToken = async (resourceUrl: ResourceUrl): Promise => { // Find resource key by URL const entry = Object.entries(RESOURCES).find(([, url]) => url === resourceUrl) if (!entry) { throw new Error(`Unknown resource: ${resourceUrl}`) } const key = entry[0] as ResourceKey const tokenInfo = tokens.value[key] // If expired, refresh all tokens if (isTokenExpired(tokenInfo)) { await refreshTokens() } const refreshedToken = tokens.value[key] if (!refreshedToken?.token) { throw new Error(`No token available for ${resourceUrl}`) } return refreshedToken.token } /** * Get token by resource key (teams, exchange, orders, kyc) */ const getTokenByKey = async (key: ResourceKey): Promise => { const tokenInfo = tokens.value[key] if (isTokenExpired(tokenInfo)) { await refreshTokens() } const refreshedToken = tokens.value[key] if (!refreshedToken?.token) { throw new Error(`No token available for ${key}`) } return refreshedToken.token } return { tokens: readonly(tokens), idToken: readonly(idToken), isRefreshing: readonly(isRefreshing), initTokens, getToken, getTokenByKey, refreshTokens } }