Initial commit from monorepo
This commit is contained in:
205
app/composables/useLogtoTokens.ts
Normal file
205
app/composables/useLogtoTokens.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user