const encoder = new TextEncoder(); const decoder = new TextDecoder(); export default { async fetch(request, env) { const url = new URL(request.url); try { if (url.pathname === '/auth/login') return startLogin(request, env); if (url.pathname === '/auth/callback') return finishLogin(request, env); if (url.pathname === '/auth/logout') return logout(request, env); if (url.pathname === '/auth/session') return sessionResponse(request, env); if (url.pathname === '/api/feedbacks') return createFeedback(request, env); if (request.method !== 'GET' && request.method !== 'HEAD') { return json({ error: 'method_not_allowed' }, 405); } if (matchesConfiguredPath(url.pathname, env.INTERNAL_ONLY_PREFIXES) && !isInternalRequest(request, env)) { return json({ error: 'not_found' }, 404); } if (isProtectedPath(url.pathname, env) && !(await getSession(request, env))) { const returnUrl = url.pathname + url.search; return Response.redirect(new URL('/auth/login?return_url=' + encodeURIComponent(returnUrl), url), 302); } if (url.pathname === '/egbim' || url.pathname === '/tova' || url.pathname === '/gaia') { return Response.redirect(new URL(url.pathname + '/', url), 301); } const objectKey = objectKeyForPath(url.pathname); let object = await env.QA_BUCKET.get(objectKey); if (!object && objectKey.endsWith('/index.html')) { object = await env.QA_BUCKET.get(objectKey.replace(/\/index\.html$/, 'index.html')); } if (!object) return new Response('Not found', { status: 404 }); const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('etag', object.httpEtag); headers.set('x-content-type-options', 'nosniff'); headers.set('referrer-policy', 'strict-origin-when-cross-origin'); headers.set('cache-control', url.pathname.endsWith('.html') ? 'no-cache' : 'public, max-age=3600'); return new Response(request.method === 'HEAD' ? null : object.body, { headers }); } catch (error) { console.error(error); return json({ error: 'internal_error' }, 500); } } }; function csv(value) { return String(value || '').split(',').map((item) => item.trim()).filter(Boolean); } function pathMatches(pathname, values) { return csv(values).some((value) => value.endsWith('/') ? pathname.startsWith(value) : pathname === value); } function matchesConfiguredPath(pathname, value) { return pathMatches(pathname, value); } function isProtectedPath(pathname, env) { return pathMatches(pathname, env.PROTECTED_EXACT_PATHS) || pathMatches(pathname, env.PROTECTED_PREFIXES); } function isInternalRequest(request, env) { const expected = env.INTERNAL_SHARED_SECRET; return Boolean(expected && request.headers.get('x-internal-secret') === expected); } function objectKeyForPath(pathname) { const normalized = pathname.replace(/^\/+/, ''); if (normalized === '') return 'index.html'; if (normalized.endsWith('/')) return normalized + 'index.html'; return normalized; } function json(payload, status = 200, extraHeaders = {}) { const headers = new Headers({ 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extraHeaders }); return new Response(JSON.stringify(payload), { status, headers }); } function redirect(url, headers = {}) { const responseHeaders = new Headers(); Object.entries(headers).forEach(([name, value]) => { if (Array.isArray(value)) value.forEach((item) => responseHeaders.append(name, item)); else responseHeaders.set(name, value); }); responseHeaders.set('location', url); return new Response(null, { status: 302, headers: responseHeaders }); } function base64url(value) { const bytes = value instanceof Uint8Array ? value : encoder.encode(value); let binary = ''; bytes.forEach((byte) => { binary += String.fromCharCode(byte); }); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } function fromBase64url(value) { const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4); const binary = atob(padded); return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } async function sha256(value) { return crypto.subtle.digest('SHA-256', typeof value === 'string' ? encoder.encode(value) : value); } async function hmac(value, secret) { const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); return crypto.subtle.sign('HMAC', key, encoder.encode(value)); } async function signValue(value, secret) { return value + '.' + base64url(new Uint8Array(await hmac(value, secret))); } async function verifyValue(signed, secret) { if (!signed || !secret) return null; const separator = signed.lastIndexOf('.'); if (separator < 1) return null; const value = signed.slice(0, separator); const signature = signed.slice(separator + 1); const expected = base64url(new Uint8Array(await hmac(value, secret))); if (signature.length !== expected.length) return null; let difference = 0; for (let index = 0; index < signature.length; index += 1) difference |= signature.charCodeAt(index) ^ expected.charCodeAt(index); return difference === 0 ? value : null; } function randomBytes(size = 32) { const bytes = new Uint8Array(size); crypto.getRandomValues(bytes); return bytes; } function randomString(size = 32) { return base64url(randomBytes(size)); } function uuidv7() { const bytes = randomBytes(16); const timestamp = Date.now(); bytes[0] = Math.floor(timestamp / 0x10000000000) & 0xff; bytes[1] = Math.floor(timestamp / 0x100000000) & 0xff; bytes[2] = Math.floor(timestamp / 0x1000000) & 0xff; bytes[3] = Math.floor(timestamp / 0x10000) & 0xff; bytes[4] = Math.floor(timestamp / 0x100) & 0xff; bytes[5] = timestamp & 0xff; bytes[6] = (bytes[6] & 0x0f) | 0x70; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } function getCookie(request, name) { const header = request.headers.get('cookie') || ''; const match = header.split(';').map((part) => part.trim()).find((part) => part.startsWith(name + '=')); return match ? match.slice(name.length + 1) : ''; } function cookie(name, value, maxAge, options = {}) { const parts = [`${name}=${value}`, `Max-Age=${maxAge}`, 'Path=/', 'Secure', 'HttpOnly', 'SameSite=Lax']; if (options.delete) parts.push('Expires=Thu, 01 Jan 1970 00:00:00 GMT'); return parts.join('; '); } function parseJwt(token) { if (!token || token.split('.').length < 2) return {}; try { return JSON.parse(decoder.decode(fromBase64url(token.split('.')[1]))); } catch (error) { return {}; } } function requiredAuthConfig(env) { const keys = ['AUTH_CLIENT_ID', 'AUTH_AUTHORIZE_URL', 'AUTH_TOKEN_URL', 'AUTH_REDIRECT_URI', 'SESSION_SECRET']; return keys.filter((key) => !env[key]); } function normalizeClaims(claims) { const profile = claims.profile && typeof claims.profile === 'object' ? claims.profile : {}; const custom = claims.customAttributes || claims.custom_attributes || {}; const profilePhones = Array.isArray(profile.phones) ? profile.phones : []; const profilePhone = profile.phone || profile.phone_number || profilePhones[0]?.number || profilePhones[0]?.value || profilePhones[0] || ''; const tenants = claims.tenants || claims.tenantIds || custom.tenants || []; const tenantList = Array.isArray(tenants) ? tenants : Object.keys(tenants).map((key) => ({ id: key, ...(tenants[key] || {}) })); const tenantId = claims.tenant_id || claims.tenantId || custom.tenant_id || custom.tenantId || tenantList[0]?.id || tenantList[0]?.tenantId || ''; const tenantIds = tenantList.map((tenant) => typeof tenant === 'string' ? tenant : (tenant.id || tenant.tenantId || tenant.key || '')).filter(Boolean); if (tenantId && !tenantIds.includes(tenantId)) tenantIds.unshift(tenantId); const primaryTenant = tenantList.find((tenant) => typeof tenant !== 'string' && (tenant.id || tenant.tenantId) === tenantId) || tenantList[0] || {}; const subject = claims.sub || claims.subject || claims.userId || claims.user_id || ''; return { userUuid: claims.userId || claims.user_id || subject, ssoSubject: subject, requesterId: subject, tenantId, requesterTenantId: tenantId, tenantIds, scope: claims.scope || '', roles: claims.roles || claims.roleNames || [], loginId: claims.email || claims.loginId || claims.preferred_username || profile.email || '', email: claims.email || profile.email || '', name: claims.name || profile.name || claims.display_name || '', phone: claims.phone || claims.phone_number || profilePhone, company: custom.company || claims.company || claims.organization || profile.company || '', familyCompany: custom.familyCompany || custom.family_company || claims.familyCompany || claims.affiliation || '', department: custom.team || custom.department || claims.department || primaryTenant.name || '', position: custom.position || claims.position || profile.position || primaryTenant.position || primaryTenant.title || '', grade: custom.grade || claims.grade || profile.grade || primaryTenant.grade || '' }; } function safeReturnUrl(value, origin) { try { const target = new URL(value || '/', origin); return target.origin === origin ? target.pathname + target.search : '/'; } catch (error) { return '/'; } } async function startLogin(request, env) { const missing = requiredAuthConfig(env); if (missing.length) return json({ error: 'auth_not_configured', missing }, 503); const url = new URL(request.url); const returnUrl = safeReturnUrl(url.searchParams.get('return_url'), url.origin); const state = randomString(24); const verifier = randomString(48); const nonce = randomString(24); const statePayload = base64url(JSON.stringify({ state, verifier, nonce, returnUrl, createdAt: Date.now() })); const signedState = await signValue(statePayload, env.SESSION_SECRET); const challenge = base64url(new Uint8Array(await sha256(verifier))); const authorize = new URL(env.AUTH_AUTHORIZE_URL); authorize.searchParams.set('client_id', env.AUTH_CLIENT_ID); authorize.searchParams.set('response_type', 'code'); authorize.searchParams.set('redirect_uri', env.AUTH_REDIRECT_URI); authorize.searchParams.set('scope', env.AUTH_SCOPE || 'openid profile email'); authorize.searchParams.set('state', state); authorize.searchParams.set('nonce', nonce); authorize.searchParams.set('code_challenge', challenge); authorize.searchParams.set('code_challenge_method', 'S256'); return redirect(authorize.toString(), { 'set-cookie': cookie(env.OAUTH_COOKIE_NAME, signedState, 600) }); } async function finishLogin(request, env) { const missing = requiredAuthConfig(env); if (missing.length) return json({ error: 'auth_not_configured', missing }, 503); const url = new URL(request.url); const signedState = getCookie(request, env.OAUTH_COOKIE_NAME); const statePayload = await verifyValue(signedState, env.SESSION_SECRET); if (!statePayload) return json({ error: 'invalid_oauth_state' }, 400); let state; try { state = JSON.parse(decoder.decode(fromBase64url(statePayload))); } catch (error) { return json({ error: 'invalid_oauth_state' }, 400); } if (url.searchParams.get('state') !== state.state || Date.now() - state.createdAt > 10 * 60 * 1000) return json({ error: 'invalid_oauth_state' }, 400); const code = url.searchParams.get('code'); if (!code) return json({ error: 'oauth_code_missing', detail: url.searchParams.get('error_description') || url.searchParams.get('error') || '' }, 400); const tokenParams = new URLSearchParams({ grant_type: 'authorization_code', client_id: env.AUTH_CLIENT_ID, redirect_uri: env.AUTH_REDIRECT_URI, code, code_verifier: state.verifier }); const tokenResponse = await fetch(env.AUTH_TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: tokenParams }); const tokens = await tokenResponse.json().catch(() => ({})); if (!tokenResponse.ok) return json({ error: 'oauth_token_exchange_failed', detail: tokens.error_description || tokens.error || '' }, 502); let claims = parseJwt(tokens.id_token || tokens.access_token); if (state.nonce && claims.nonce && state.nonce !== claims.nonce) return json({ error: 'invalid_oauth_nonce' }, 400); if (env.AUTH_USERINFO_URL && tokens.access_token) { const userResponse = await fetch(env.AUTH_USERINFO_URL, { headers: { authorization: `Bearer ${tokens.access_token}`, accept: 'application/json' } }); const userInfo = await userResponse.json().catch(() => ({})); if (userResponse.ok) claims = { ...claims, ...userInfo }; } const user = normalizeClaims(claims); if (!user.ssoSubject) return json({ error: 'sso_subject_missing' }, 502); const sessionPayload = base64url(JSON.stringify({ user, expiresAt: Date.now() + Number(env.SESSION_TTL_SECONDS || 3600) * 1000 })); const session = await signValue(sessionPayload, env.SESSION_SECRET); return redirect(new URL(state.returnUrl || '/', url.origin).toString(), { 'set-cookie': [cookie(env.SESSION_COOKIE_NAME, session, Number(env.SESSION_TTL_SECONDS || 3600)), cookie(env.OAUTH_COOKIE_NAME, '', 0, { delete: true })] }); } async function getSession(request, env) { const signedSession = getCookie(request, env.SESSION_COOKIE_NAME); const value = await verifyValue(signedSession, env.SESSION_SECRET); if (!value) return null; try { const payload = JSON.parse(decoder.decode(fromBase64url(value))); return payload.expiresAt > Date.now() ? payload.user : null; } catch (error) { return null; } } async function sessionResponse(request, env) { const user = await getSession(request, env); return json({ authenticated: Boolean(user), user }, 200, { 'access-control-allow-credentials': 'true' }); } async function createFeedback(request, env) { if (request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, { allow: 'POST' }); const user = await getSession(request, env); if (!user) return json({ error: 'unauthenticated' }, 401); if (!env.ABC_API_KEY) return json({ error: 'feedback_api_not_configured' }, 503); if (!env.ABC_PROJECT_ID || !env.ABC_CHANNEL_ID) return json({ error: 'feedback_target_not_configured' }, 503); let input; try { input = await request.json(); } catch (error) { return json({ error: 'invalid_json' }, 400); } if (!input || typeof input !== 'object' || Array.isArray(input)) return json({ error: 'invalid_json' }, 400); const title = String(input.title || '').trim(); const contents = String(input.contents || '').trim(); const category = String(input.Category || '').trim(); if (!title || !contents || !category) return json({ error: 'title_contents_category_required' }, 400); if (!['ERROR_QNA', 'IMPROVEMENT_QNA', 'GENERAL_QNA'].includes(category)) return json({ error: 'invalid_category' }, 400); const feedbackId = isUuidv7(input.feedbackId) ? input.feedbackId : uuidv7(); const requesterId = user.requesterId || user.ssoSubject || user.userUuid; const requesterTenantId = user.requesterTenantId || user.tenantId; if (!requesterId || !requesterTenantId) return json({ error: 'requester_identity_missing' }, 422); if (!user.phone) return json({ error: 'requester_phone_missing' }, 422); const upstreamPayload = { title, contents, Category: category, requester_id: requesterId, requester_tenant_id: requesterTenantId, requester_email: user.email || '', requester_name: user.name || '', requester_department: user.department || '', requester_phone_number: user.phone, requester_uuid: user.userUuid || requesterId, requester_affiliation: user.familyCompany || user.company || '', requester_position: user.position || '', requester_grade: user.grade || '', is_secret: input.is_secret ? 1 : 0 }; if (Array.isArray(input.images) && input.images.length) upstreamPayload.images = input.images; const apiBaseUrl = String(env.ABC_API_BASE_URL || 'https://feedback.hmac.kr').replace(/\/$/, ''); const endpoint = `${apiBaseUrl}/api/projects/${encodeURIComponent(env.ABC_PROJECT_ID)}/channels/${encodeURIComponent(env.ABC_CHANNEL_ID)}/feedbacks`; const sourceNamespace = env.ABC_SOURCE_NAMESPACE || 'EGBIM_QA'; const idempotencyConsumer = env.ABC_IDEMPOTENCY_CONSUMER || sourceNamespace; let response; try { response = await fetch(endpoint, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', 'x-api-key': env.ABC_API_KEY, 'x-source-namespace': sourceNamespace, 'x-source-record-id': feedbackId, 'x-idempotency-consumer': idempotencyConsumer, 'idempotency-key': feedbackId }, body: JSON.stringify(upstreamPayload) }); } catch (error) { console.error('feedback_api_request_failed', error instanceof Error ? error.message : 'unknown'); return json({ error: 'feedback_api_unreachable' }, 502); } const result = await response.json().catch(() => ({})); if (!response.ok) { console.error('feedback_api_rejected', response.status); const upstreamMessage = String(result.message || result.error || result.code || '').slice(0, 240); return json({ error: 'feedback_api_rejected', message: upstreamMessage || 'ABC API가 문의 저장을 거부했습니다.', status: response.status }, response.status >= 500 ? 502 : response.status); } const id = result.id || result.data?.id || result.feedback?.id; if (!id) return json({ error: 'feedback_id_missing' }, 502); return json({ id }); } function isUuidv7(value) { return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } function logout(request, env) { const url = new URL(request.url); const target = safeReturnUrl(env.AUTH_POST_LOGOUT_REDIRECT_URI || '/', url.origin); return redirect(new URL(target, url.origin).toString(), { 'set-cookie': cookie(env.SESSION_COOKIE_NAME, '', 0, { delete: true }) }); }