258 lines
11 KiB
TypeScript
258 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 }
|
||
|
|
}
|