eCourt-Doc-Edit/web/app/composables/useAuth.ts
2026-08-20 06:20:16 +07:00

257 lines
11 KiB
TypeScript

/**
* 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<MemoryTokens> | 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<AuthState>('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<TokenResponse> {
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<CallbackResult> {
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<MemoryTokens> {
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<string> {
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<void> {
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 }
}