206 lines
5.3 KiB
TypeScript
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
|
|
}
|
|
}
|