76 lines
2.9 KiB
TypeScript
76 lines
2.9 KiB
TypeScript
|
|
/**
|
|||
|
|
* Helpers สำหรับ OIDC + PKCE — เป็น pure functions (แยกจาก useAuth) เพื่อ unit test ได้ง่าย
|
|||
|
|
* เว็บเป็น top-level page จึงใช้ browser redirect ปกติได้ (ไม่ต้องมี Office Dialog API แบบ add-in)
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
/** เข้ารหัส bytes เป็น base64url (ไม่มี padding) ตาม RFC 7636 */
|
|||
|
|
export function toBase64Url(bytes: Uint8Array): string {
|
|||
|
|
let binary = ''
|
|||
|
|
for (const b of bytes) binary += String.fromCharCode(b)
|
|||
|
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** code_verifier ของ PKCE — 48 random bytes → base64url 64 อักขระ (อยู่ในช่วง 43–128 ตาม spec) */
|
|||
|
|
export function generateCodeVerifier(): string {
|
|||
|
|
const bytes = new Uint8Array(48)
|
|||
|
|
crypto.getRandomValues(bytes)
|
|||
|
|
return toBase64Url(bytes)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** state สุ่มสำหรับกัน CSRF — เทียบตอนรับ code กลับ */
|
|||
|
|
export function generateState(): string {
|
|||
|
|
const bytes = new Uint8Array(16)
|
|||
|
|
crypto.getRandomValues(bytes)
|
|||
|
|
return toBase64Url(bytes)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** code_challenge = base64url(SHA-256(verifier)) วิธี S256 */
|
|||
|
|
export async function createCodeChallenge(verifier: string): Promise<string> {
|
|||
|
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|||
|
|
return toBase64Url(new Uint8Array(digest))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface AuthorizeUrlParams {
|
|||
|
|
authorizeEndpoint: string
|
|||
|
|
clientId: string
|
|||
|
|
redirectUri: string
|
|||
|
|
state: string
|
|||
|
|
codeChallenge: string
|
|||
|
|
scope?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** ประกอบ authorization URL แบบ authorization code + PKCE S256 */
|
|||
|
|
export function buildAuthorizeUrl(params: AuthorizeUrlParams): string {
|
|||
|
|
const qs = new URLSearchParams({
|
|||
|
|
client_id: params.clientId,
|
|||
|
|
response_type: 'code',
|
|||
|
|
scope: params.scope ?? 'openid',
|
|||
|
|
redirect_uri: params.redirectUri,
|
|||
|
|
state: params.state,
|
|||
|
|
code_challenge: params.codeChallenge,
|
|||
|
|
code_challenge_method: 'S256',
|
|||
|
|
})
|
|||
|
|
return `${params.authorizeEndpoint}?${qs.toString()}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** รูป response ของ token endpoint (เฉพาะ field ที่เราใช้) */
|
|||
|
|
export interface TokenResponse {
|
|||
|
|
access_token: string
|
|||
|
|
refresh_token?: string
|
|||
|
|
id_token?: string
|
|||
|
|
expires_in?: number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** decode payload ของ JWT เพื่ออ่าน claims (exp / preferred_username) — token ไม่ถูกต้องคืน null ไม่ throw */
|
|||
|
|
export function parseJwtPayload(token: string): Record<string, unknown> | null {
|
|||
|
|
const parts = token.split('.')
|
|||
|
|
if (parts.length !== 3) return null
|
|||
|
|
try {
|
|||
|
|
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
|||
|
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
|
|||
|
|
return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
|
|||
|
|
} catch {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
}
|