/** * FR-8 ฝั่งเว็บ — OIDC authorization code + PKCE S256 ด้วย browser redirect ปกติ * (เว็บเป็น top-level page จึงไม่ต้องใช้ Office Dialog API แบบ add-in) * * การเก็บค่าตามกติกา: * - access/refresh/id token เก็บใน memory ของแท็บ (useState) เท่านั้น — ห้าม localStorage * - สิ่งที่ต้องข้าม navigation: code_verifier + state (ตอนรอ redirect กลับ) และ refresh token * (ไว้ restore session หลัง reload) — ใช้ sessionStorage อายุสั้นตามที่แผนอนุญาต */ import { buildAuthorizeUrl, createCodeChallenge, generateCodeVerifier, generateState, parseJwtPayload } from '~/utils/oidc' import type { TokenResponse } from '~/utils/oidc' import type { LocationQuery } from 'vue-router' const PENDING_KEY = 'wv-oidc-pending' const SESSION_KEY = 'wv-session' interface PendingLogin { verifier: string state: string returnTo: string } /** ส่วนที่ persist ใน sessionStorage — ไม่มี access token */ interface StoredSession { refreshToken: string idToken?: string username?: string } interface MemoryTokens { accessToken: string refreshToken: string idToken?: string /** epoch ms ที่ token หมดอายุ */ expiresAt: number } interface AuthState { tokens: MemoryTokens | null username: string | null /** false ระหว่าง restore session ตอน boot — กัน UI กระพริบไปหน้า login ก่อนรู้ผล */ ready: boolean } export interface CallbackResult { ok: boolean returnTo?: string error?: string } export class AuthError extends Error {} // dedupe concurrent refresh (หลาย request เจอ 401 พร้อมกัน) — ใช้เฉพาะฝั่ง client let refreshInFlight: Promise | null = null export function useAuth() { const config = useRuntimeConfig() const authority = config.public.keycloakAuthority as string const clientId = config.public.keycloakClientId as string const state = useState('auth', () => ({ tokens: null, username: null, ready: false })) const isAuthenticated = computed(() => state.value.tokens !== null) function readPending(): PendingLogin | null { try { const raw = sessionStorage.getItem(PENDING_KEY) return raw ? (JSON.parse(raw) as PendingLogin) : null } catch { return null } } function writeSession(session: StoredSession | null) { if (session === null) sessionStorage.removeItem(SESSION_KEY) else sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)) } function readSession(): StoredSession | null { try { const raw = sessionStorage.getItem(SESSION_KEY) return raw ? (JSON.parse(raw) as StoredSession) : null } catch { return null } } /** บันทึก token ลง memory + persist เฉพาะ refresh/id token ลง sessionStorage */ function setTokens(res: TokenResponse) { const claims = parseJwtPayload(res.access_token) const expiresAt = res.expires_in ? Date.now() + res.expires_in * 1000 : typeof claims?.exp === 'number' ? claims.exp * 1000 : Date.now() + 60_000 const username = (typeof claims?.preferred_username === 'string' && claims.preferred_username) || (typeof claims?.sub === 'string' && claims.sub) || state.value.username || readSession()?.username || '' const tokens: MemoryTokens = { accessToken: res.access_token, refreshToken: res.refresh_token ?? state.value.tokens?.refreshToken ?? readSession()?.refreshToken ?? '', idToken: res.id_token ?? state.value.tokens?.idToken ?? readSession()?.idToken, expiresAt, } if (!tokens.refreshToken) throw new AuthError('Keycloak ไม่ได้ส่ง refresh_token กลับมา') state.value.tokens = tokens state.value.username = username || null writeSession({ refreshToken: tokens.refreshToken, idToken: tokens.idToken, username: state.value.username }) } /** เข้าสู่ระบบ — สร้าง PKCE pair แล้ว redirect ไป Keycloak (เต็มหน้า) */ async function login(returnTo?: string) { if (!import.meta.client) return const verifier = generateCodeVerifier() const pending: PendingLogin = { verifier, state: generateState(), returnTo: returnTo ?? new URL(window.location.href).pathname, } sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending)) const challenge = await createCodeChallenge(verifier) const url = buildAuthorizeUrl({ authorizeEndpoint: `${authority}/protocol/openid-connect/auth`, clientId, redirectUri: `${window.location.origin}/auth/callback`, state: pending.state, codeChallenge: challenge, }) window.location.assign(url) } async function postTokenEndpoint(body: URLSearchParams): Promise { const res = await fetch(`${authority}/protocol/openid-connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }) if (!res.ok) { let detail = '' try { const json = (await res.json()) as { error_description?: string; error?: string } detail = json.error_description ?? json.error ?? '' } catch { /* body ไม่ใช่ JSON — ใช้ status อย่างเดียว */ } throw new AuthError(detail || `Keycloak ตอบ HTTP ${res.status}`) } return (await res.json()) as TokenResponse } /** รับ code จากหน้า /auth/callback — validate state แล้วแลก token (เรียกจาก onMounted ของหน้า callback) */ async function handleCallback(query: LocationQuery): Promise { if (typeof query.error === 'string') return { ok: false, error: `การเข้าสู่ระบบถูกปฏิเสธ: ${query.error_description ?? query.error}` } if (typeof query.code !== 'string' || typeof query.state !== 'string') return { ok: false, error: 'ไม่พบ authorization code ใน URL' } const pending = readPending() sessionStorage.removeItem(PENDING_KEY) if (pending === null) return { ok: false, error: 'ไม่พบข้อมูลการเข้าสู่ระบบที่ค้างอยู่ (แท็บอาจถูกปิดระหว่างรอ) — ลองเข้าสู่ระบบใหม่' } if (pending.state !== query.state) return { ok: false, error: 'state ไม่ตรงกัน (อาจเป็นการโจมตี CSRF) — ยกเลิกการเข้าสู่ระบบ' } try { const tokens = await postTokenEndpoint( new URLSearchParams({ grant_type: 'authorization_code', code: query.code, redirect_uri: `${window.location.origin}/auth/callback`, client_id: clientId, code_verifier: pending.verifier, }), ) setTokens(tokens) // กัน open redirect — รับเฉพาะ path ภายในแอปเท่านั้น const returnTo = pending.returnTo.startsWith('/') && !pending.returnTo.startsWith('//') ? pending.returnTo : '/' return { ok: true, returnTo } } catch (err) { return { ok: false, error: err instanceof Error ? err.message : 'แลก token ไม่สำเร็จ' } } } /** refresh token — คืน token ชุดใหม่ / throw เมื่อ refresh token ตาย (ต้อง login ใหม่) */ async function refresh(): Promise { const currentRefresh = state.value.tokens?.refreshToken ?? readSession()?.refreshToken if (!currentRefresh) { state.value.tokens = null state.value.username = null writeSession(null) throw new AuthError('ไม่มี session ให้ต่ออายุ — กรุณาเข้าสู่ระบบใหม่') } try { const tokens = await postTokenEndpoint( new URLSearchParams({ grant_type: 'refresh_token', refresh_token: currentRefresh, client_id: clientId, }), ) setTokens(tokens) return state.value.tokens } catch (err) { // refresh token ตาย/ถูก revoke — เคลียร์ session แล้วให้ UI กลับไปหน้า login แบบ graceful state.value.tokens = null state.value.username = null writeSession(null) throw new AuthError(err instanceof Error ? `เซสชันหมดอายุ (${err.message}) — กรุณาเข้าสู่ระบบใหม่` : 'เซสชันหมดอายุ — กรุณาเข้าสู่ระบบใหม่') } } /** access token ที่ยังใช้ได้ — refresh เงียบๆ ก่อนหมดอายุ (เหลือ < 30 วิ ถือว่าใกล้หมด) */ async function getAccessToken(forceRefresh = false): Promise { const t = state.value.tokens if (!forceRefresh && t && t.expiresAt - 30_000 > Date.now()) return t.accessToken if (refreshInFlight) return (await refreshInFlight).accessToken refreshInFlight = refresh().finally(() => (refreshInFlight = null)) return (await refreshInFlight).accessToken } /** ตอน boot — มี session ใน sessionStorage ก็ต่ออาย่เพื่อกลับมา login อยู่โดยไม่ต้อง redirect */ async function restoreSession(): Promise { if (!import.meta.client) { state.value.ready = true return } try { if (readSession()) await getAccessToken(true) } catch { /* refresh พัง = ไม่ login อยู่ — แค่นั้น */ } finally { state.value.ready = true } } /** ออกจากระบบ — เคลียร์ของในเครื่องทั้งหมดแล้วปิด session ฝั่ง Keycloak ด้วย */ function logout() { if (!import.meta.client) return const idToken = state.value.tokens?.idToken ?? readSession()?.idToken state.value.tokens = null state.value.username = null sessionStorage.removeItem(PENDING_KEY) writeSession(null) const qs = new URLSearchParams({ post_logout_redirect_uri: `${window.location.origin}/` }) if (idToken) qs.set('id_token_hint', idToken) window.location.assign(`${authority}/protocol/openid-connect/logout?${qs.toString()}`) } return { state, isAuthenticated, username: computed(() => state.value.username), ready: computed(() => state.value.ready), login, handleCallback, getAccessToken, restoreSession, logout } }