Files
webapp/app/composables/useLogtoTokens.ts
2026-01-07 09:10:35 +07:00

206 lines
5.3 KiB
TypeScript

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<Record<ResourceKey, TokenInfo>>
}
// 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<Partial<Record<ResourceKey, TokenInfo>>>('logto-tokens', () => ({}))
const idToken = useState<string | null>('logto-id-token', () => null)
const isRefreshing = ref(false)
let refreshPromise: Promise<void> | 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<string | null>('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<Record<ResourceKey, TokenInfo>> = {}
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<void> => {
// Deduplicate concurrent refresh calls
if (refreshPromise) {
return refreshPromise
}
isRefreshing.value = true
refreshPromise = (async () => {
try {
const response = await $fetch<RefreshResponse>('/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<string> => {
// 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<string> => {
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
}
}