diff --git a/nuxt.config.ts b/nuxt.config.ts index 1c3cc76..c8242ca 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -102,6 +102,9 @@ export default defineNuxtConfig({ wsBaseUrl: process.env.WS_BASE_URL || 'ws://10.10.150.100:8084/api/v1/ws', baseUrl: process.env.BASE_URL || 'http://10.10.150.144:8080/api', baseUrlGomed: process.env.BASE_URL_GOMED || 'https://gomed.rssa.my.id/api', + keycloakUrl: process.env.KEYCLOAK_ISSUER ? `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token` : 'https://auth.rssa.top/realms/sandbox/protocol/openid-connect/token', + keycloakClientId: process.env.KEYCLOAK_CLIENT_ID || 'akbar-test', + keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET || 'FDyv3UYMgJOYPnvzXVVv6diRtcgEevKg', }, }, // auth: { diff --git a/services/api.ts b/services/api.ts index e8344bf..4788326 100644 --- a/services/api.ts +++ b/services/api.ts @@ -3,6 +3,11 @@ import axios from 'axios' // Get runtime config for Nuxt 3 const config = useRuntimeConfig() +// Keycloak configuration for token refresh +const KEYCLOAK_TOKEN_URL = (config.public.keycloakUrl as string) || 'https://auth.rssa.top/realms/sandbox/protocol/openid-connect/token' +const KEYCLOAK_CLIENT_ID = (config.public.keycloakClientId as string) || 'akbar-test' +const KEYCLOAK_CLIENT_SECRET = (config.public.keycloakClientSecret as string) || 'FDyv3UYMgJOYPnvzXVVv6diRtcgEevKg' + // Create axios instance for local API const api = axios.create({ baseURL: config.public.baseUrl, @@ -15,6 +20,68 @@ const apiGomed = axios.create({ timeout: 10000, }) +// Flag to prevent multiple simultaneous refresh attempts +let isRefreshing = false +let failedQueue: any[] = [] + +const processQueue = (error: any, token: string | null = null) => { + failedQueue.forEach(prom => { + if (error) { + prom.reject(error) + } else { + prom.resolve(token) + } + }) + + failedQueue = [] +} + +const refreshAccessToken = async (): Promise => { + try { + const refreshToken = localStorage.getItem('refreshToken') + if (!refreshToken) { + throw new Error('No refresh token available') + } + + const params = new URLSearchParams() + params.append('grant_type', 'refresh_token') + params.append('client_id', KEYCLOAK_CLIENT_ID) + params.append('client_secret', KEYCLOAK_CLIENT_SECRET) + params.append('refresh_token', refreshToken) + + const response = await fetch(KEYCLOAK_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString() + }) + + if (!response.ok) { + throw new Error('Token refresh failed') + } + + const data = await response.json() + + // Update tokens in localStorage + if (data.access_token) { + localStorage.setItem('accessToken', data.access_token) + } + if (data.id_token) { + localStorage.setItem('idToken', data.id_token) + } + if (data.refresh_token) { + localStorage.setItem('refreshToken', data.refresh_token) + } + + console.log('✅ Token refreshed successfully') + return data.id_token || data.access_token + } catch (error) { + console.error('❌ Token refresh failed:', error) + return null + } +} + // Shared request interceptor const requestInterceptor = (config: any) => { // Only access localStorage on client-side (avoid SSR errors) @@ -31,9 +98,68 @@ const requestInterceptor = (config: any) => { // Shared response interceptor const responseInterceptor = (response: any) => response -const responseErrorInterceptor = (error: any) => { +const responseErrorInterceptor = async (error: any) => { + const originalRequest = error.config + // centralized error console.error('API Error:', error.response) + + // Handle token expiration on client-side + if (process.client && error.response) { + const { status } = error.response + + // Check if response is 401 (token expired or unauthorized) + if (status === 401 && !originalRequest._retry) { + if (isRefreshing) { + // If already refreshing, queue this request + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }) + }) + .then(token => { + originalRequest.headers['Authorization'] = 'Bearer ' + token + return axios(originalRequest) + }) + .catch(err => { + return Promise.reject(err) + }) + } + + originalRequest._retry = true + isRefreshing = true + + // Try to refresh the token + const newToken = await refreshAccessToken() + + if (newToken) { + // Token refresh successful, retry original request + isRefreshing = false + processQueue(null, newToken) + + originalRequest.headers['Authorization'] = 'Bearer ' + newToken + return axios(originalRequest) + } else { + // Token refresh failed (refresh token expired or error), logout user + isRefreshing = false + processQueue(new Error('Token refresh failed'), null) + + // Clear expired tokens + localStorage.removeItem('idToken') + localStorage.removeItem('accessToken') + localStorage.removeItem('refreshToken') + + // Call logout endpoint to clear session + fetch('/api/auth/logout', { method: 'POST' }) + .catch(err => console.error('Logout failed:', err)) + + // Redirect to login with error message + const errorMessage = encodeURIComponent('Token is expired') + window.location.href = `/auth/login?error=${errorMessage}` + + return Promise.reject(error) + } + } + } + return Promise.reject(error) }