diff --git a/QA_platform_icon.png b/QA_platform_icon.png new file mode 100644 index 0000000..1ef6132 Binary files /dev/null and b/QA_platform_icon.png differ diff --git a/apps/secretary-api/app/api/routes/tickets.py b/apps/secretary-api/app/api/routes/tickets.py index 7eb948d..5e8216f 100644 --- a/apps/secretary-api/app/api/routes/tickets.py +++ b/apps/secretary-api/app/api/routes/tickets.py @@ -4,6 +4,7 @@ from typing import Annotated, cast from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from fastapi.responses import FileResponse, Response from sqlalchemy.orm import Session +from starlette.datastructures import UploadFile as StarletteUploadFile from urllib.parse import quote from app.core.auth import SsoPrincipal, get_principal @@ -36,6 +37,7 @@ from app.services.workspace_mapping_service import workspace_mapping_service router = APIRouter(prefix="/workspaces", tags=["tickets"]) service = TicketService() access_service = AccessService() +MAX_ATTACHMENT_SIZE_BYTES = 30 * 1024 * 1024 def can_manage_ticket_comments( @@ -250,7 +252,10 @@ async def create_ticket( content_type = request.headers.get("content-type", "") if content_type.startswith("multipart/form-data"): - form = await request.form() + form = await request.form( + max_files=10, + max_part_size=MAX_ATTACHMENT_SIZE_BYTES, + ) extra_fields_value = form.get("extra_fields") extra_fields = ( json.loads(extra_fields_value) diff --git a/apps/web/.env.example b/apps/web/.env.example index 66f81a2..f222ffa 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -5,7 +5,7 @@ SSO_ISSUER=https://sso.hmac.kr/oidc SSO_AUTHORIZATION_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/auth SSO_TOKEN_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/token SSO_USERINFO_ENDPOINT=https://sso.hmac.kr/oidc/userinfo -SSO_SCOPE=openid profile email +SSO_SCOPE=openid profile email tenants SSO_REDIRECT_URI= SSO_CLIENT_ID= SSO_CLIENT_SECRET= diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index e5279fb..8a53104 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -61,6 +61,48 @@ function App({ Component, pageProps }: AppPropsWithLayout) { const { setUser, user, randomId } = useUserStore(); const router = useRouter(); + useEffect(() => { + const readCookie = (name: string) => { + const prefix = `${name}=`; + const cookie = document.cookie.split('; ').find((value) => value.startsWith(prefix)); + return cookie ? cookie.slice(prefix.length) : null; + }; + + const cookieCount = Number(readCookie('baron_sso_debug_tenants_count') ?? 0); + const cookie = + cookieCount > 0 ? + Array.from({ length: cookieCount }, (_, index) => + readCookie(`baron_sso_debug_tenants_${index}`) ?? '', + ).join('') + : readCookie('baron_sso_debug_tenants'); + + if (!cookie) { + window.console.warn( + '[SSO OAuth debug] callback 디버그 쿠키가 없습니다. 로그아웃 후 다시 로그인하세요.', + ); + return; + } + + try { + const data = JSON.parse( + decodeURIComponent(cookie), + ) as { + requestedScopes?: unknown; + tokenResponseScope?: unknown; + tokenType?: unknown; + idTokenClaims?: unknown; + accessTokenClaims?: unknown; + }; + window.console.log('[SSO OAuth requested scopes]', data.requestedScopes); + window.console.log('[SSO OAuth token response scope]', data.tokenResponseScope); + window.console.log('[SSO OAuth token type]', data.tokenType); + window.console.log('[SSO ID token claims]', data.idTokenClaims); + window.console.log('[SSO access token claims]', data.accessTokenClaims); + } catch (error) { + console.warn('[SSO tenants] JSON parsing failed', error); + } + }, []); + useEffect(() => { if (user) return; void setUser(); diff --git a/apps/web/src/pages/api/auth/baron-sso/debug.ts b/apps/web/src/pages/api/auth/baron-sso/debug.ts new file mode 100644 index 0000000..c21cb2f --- /dev/null +++ b/apps/web/src/pages/api/auth/baron-sso/debug.ts @@ -0,0 +1,12 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { getSsoDebugInfo } from '@/server/local-sso'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') return res.status(405).send('Method not allowed'); + + const debugInfo = await getSsoDebugInfo(req); + if (!debugInfo) return res.status(404).json({ message: 'SSO debug information is unavailable.' }); + + return res.status(200).json(debugInfo); +} diff --git a/apps/web/src/pages/api/support/tickets/[ticketId].ts b/apps/web/src/pages/api/support/tickets/[ticketId].ts index e26cfd5..0766669 100644 --- a/apps/web/src/pages/api/support/tickets/[ticketId].ts +++ b/apps/web/src/pages/api/support/tickets/[ticketId].ts @@ -25,6 +25,7 @@ import { import { getSupportAuthHeaders, getSupportPrincipal, + getSupportSsoClaims, isSupportAdmin, } from '@/server/support-auth'; import { supportExternalUrl } from '@/server/support-external'; @@ -42,6 +43,137 @@ const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true'; const getFeedbackString = (feedback: Record, key: string) => typeof feedback[key] === 'string' ? feedback[key] : ''; +const asRecord = (value: unknown): Record | null => + value && typeof value === 'object' && !Array.isArray(value) ? + (value as Record) + : null; + +const getStringValue = (value: unknown) => + typeof value === 'string' && value.trim() ? value.trim() : ''; + +const getRequesterSsoFields = (feedback: Record) => { + const extraFields = asRecord(feedback.extra_fields) ?? {}; + const rawClaims = feedback.requester_sso_claims ?? extraFields.requester_sso_claims; + let claims = asRecord(rawClaims); + if (!claims && typeof rawClaims === 'string') { + try { + claims = asRecord(JSON.parse(rawClaims)); + } catch { + claims = null; + } + } + if (!claims) return {}; + + const tokenSources = [ + claims, + asRecord(claims.id_token), + asRecord(claims.access_token), + asRecord(claims.userinfo), + asRecord(claims.user_info), + ].filter((source): source is Record => source !== null); + const profileSources = tokenSources + .flatMap((source) => [source, asRecord(source.profile)]) + .filter((source): source is Record => source !== null); + const readClaim = (...keys: string[]) => { + for (const source of profileSources) { + for (const key of keys) { + const value = getStringValue(source[key]); + if (value) return value; + } + } + return ''; + }; + + const tenants = new Map>(); + for (const source of tokenSources) { + const tenantMap = asRecord(source.tenants); + if (!tenantMap) continue; + for (const tenant of Object.values(tenantMap)) { + const record = asRecord(tenant); + const key = getStringValue(record?.id) || getStringValue(record?.slug); + if (record && key) tenants.set(key, record); + } + } + + const tenantList = [...tenants.values()]; + const primaryTenant = + tenantList.find((tenant) => tenant.isPrimary === true) ?? + tenantList.find((tenant) => tenant.representative === true) ?? + tenantList[0]; + const ancestors = Array.isArray(primaryTenant?.ancestors) ? + primaryTenant.ancestors + .map((ancestor) => asRecord(ancestor)) + .filter((ancestor): ancestor is Record => ancestor !== null) + : []; + const companyAffiliation = ancestors.find( + (ancestor) => getStringValue(ancestor.type) === 'COMPANY', + ); + + return { + requester_id: readClaim('sub', 'user_id', 'id'), + requester_email: readClaim('email'), + requester_name: readClaim('name'), + requester_department: + readClaim('department') || getStringValue(primaryTenant?.name), + requester_affiliation: + getStringValue(companyAffiliation?.name) || + readClaim('affiliationType', 'affiliation_type') || + getStringValue(primaryTenant?.name), + requester_position: + readClaim('position', 'jobTitle', 'job_title') || + getStringValue(primaryTenant?.position) || + getStringValue(primaryTenant?.jobTitle) || + getStringValue(primaryTenant?.grade), + requester_grade: readClaim('grade') || getStringValue(primaryTenant?.grade), + requester_employee_id: readClaim( + 'employee_id', + 'employeeId', + 'employee_number', + 'employeeNumber', + ), + }; +}; + +const enrichRequesterFields = ( + ticket: Record, + sessionClaims?: Record | null, +) => { + const ticketSsoFields = getRequesterSsoFields(ticket); + const sessionSsoFields = sessionClaims ? + getRequesterSsoFields({ requester_sso_claims: sessionClaims }) + : {}; + const ticketRequesterId = getFeedbackString(ticket, 'requester_id'); + const ticketRequesterEmail = getFeedbackString(ticket, 'requester_email'); + const isCurrentRequester = + !ticketRequesterId && !ticketRequesterEmail || + ticketRequesterId !== '' && + ticketRequesterId === sessionSsoFields.requester_id || + ticketRequesterEmail !== '' && + ticketRequesterEmail === sessionSsoFields.requester_email; + const requesterSsoFields = isCurrentRequester ? + { ...sessionSsoFields, ...ticketSsoFields } + : ticketSsoFields; + + return { + ...ticket, + requester_department: + getFeedbackString(ticket, 'requester_department') || + requesterSsoFields.requester_department, + requester_affiliation: + getFeedbackString(ticket, 'requester_affiliation') || + requesterSsoFields.requester_affiliation, + requester_position: + getFeedbackString(ticket, 'requester_position') || + requesterSsoFields.requester_position, + requester_grade: + getFeedbackString(ticket, 'requester_grade') || + requesterSsoFields.requester_grade, + requester_employee_id: + getFeedbackString(ticket, 'requester_employee_id') || + requesterSsoFields.requester_employee_id, + }; +}; + const getFeedbackFlag = (feedback: Record, key: string) => { const value = feedback[key]; return ( @@ -176,6 +308,7 @@ const synthesizeSupportTicketFromAbc = async ( currentIssue && typeof currentIssue.status === 'string' ? currentIssue.status : null; + const requesterSsoFields = getRequesterSsoFields(feedback); return { ticket_id: ticketId, @@ -189,6 +322,15 @@ const synthesizeSupportTicketFromAbc = async ( requester_email: getFeedbackString(feedback, 'requester_email'), requester_name: getFeedbackString(feedback, 'requester_name'), requester_department: getFeedbackString(feedback, 'requester_department'), + requester_affiliation: + getFeedbackString(feedback, 'requester_affiliation') || + requesterSsoFields.requester_affiliation, + requester_position: + getFeedbackString(feedback, 'requester_position') || + requesterSsoFields.requester_position, + requester_grade: + getFeedbackString(feedback, 'requester_grade') || + requesterSsoFields.requester_grade, requester_phone_number: getFeedbackString(feedback, 'requester_phone_number') || getFeedbackString(feedback, 'phone_number'), @@ -233,6 +375,7 @@ const synthesizeAbcTicketFromFeedback = async ( : null; const { title, description } = getAbcFeedbackContent(feedback); const legacyRequester = getLegacyRequester(feedback); + const requesterSsoFields = getRequesterSsoFields(feedback); const getString = (key: string) => { const aliases = key === 'ip_address' ? ['ip_address', 'IP'] @@ -269,6 +412,9 @@ const synthesizeAbcTicketFromFeedback = async ( 'requester_email', 'requester_name', 'requester_department', + 'requester_affiliation', + 'requester_position', + 'requester_grade', 'requester_phone_number', 'is_secret', ]) { @@ -288,6 +434,12 @@ const synthesizeAbcTicketFromFeedback = async ( requester_email: getString('requester_email'), requester_name: getString('requester_name'), requester_department: getString('requester_department'), + requester_affiliation: + getString('requester_affiliation') || requesterSsoFields.requester_affiliation, + requester_position: + getString('requester_position') || requesterSsoFields.requester_position, + requester_grade: + getString('requester_grade') || requesterSsoFields.requester_grade, category_code: getString('category_code') || getString('Category') || 'GENERAL', ticket_type: 'GENERAL', @@ -307,7 +459,12 @@ const synthesizeAbcTicketFromFeedback = async ( typeof feedback.updatedAt === 'string' ? feedback.updatedAt : new Date().toISOString(), - extra_fields: extraFields, + extra_fields: { + ...extraFields, + ...Object.fromEntries( + Object.entries(requesterSsoFields).filter(([, value]) => value), + ), + }, attachments: getAbcFeedbackAttachments(feedback), activity: [], }; @@ -334,7 +491,12 @@ const handler = createNextApiHandler({ { headers: getSupportAuthHeaders(req) }, ); const data = (await response.json()) as unknown; - return res.status(response.status).json(data); + const sessionClaims = getSupportSsoClaims(req); + return res.status(response.status).json( + data && typeof data === 'object' && !Array.isArray(data) ? + enrichRequesterFields(data as Record, sessionClaims) + : data, + ); } catch { return res .status(503) diff --git a/apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts index 80f6efa..d5e5795 100644 --- a/apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts +++ b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts @@ -8,10 +8,89 @@ import type { } from 'formidable'; import { createNextApiHandler } from '@/server/api-handler'; -import { getSupportAuthHeaders } from '@/server/support-auth'; +import { getSupportAuthHeaders, getSupportSsoClaims } from '@/server/support-auth'; import { supportExternalUrl } from '@/server/support-external'; const MAX_UPLOAD_SIZE_BYTES = 30 * 1024 * 1024; + +const asRecord = (value: unknown): Record | null => + value && typeof value === 'object' && !Array.isArray(value) ? + (value as Record) + : null; + +const getStringValue = (value: unknown) => + typeof value === 'string' && value.trim() ? value.trim() : ''; + +const getSsoRequesterFields = (claims: Record) => { + const tokenSources = [ + claims, + asRecord(claims.id_token), + asRecord(claims.access_token), + asRecord(claims.userinfo), + asRecord(claims.user_info), + ].filter((source): source is Record => source !== null); + const profileSources = tokenSources + .flatMap((source) => [source, asRecord(source.profile)]) + .filter((source): source is Record => source !== null); + const readClaim = (...keys: string[]) => { + for (const source of profileSources) { + for (const key of keys) { + const value = getStringValue(source[key]); + if (value) return value; + } + } + return ''; + }; + + const tenants = new Map>(); + for (const source of tokenSources) { + const tenantMap = asRecord(source.tenants); + if (!tenantMap) continue; + for (const tenant of Object.values(tenantMap)) { + const record = asRecord(tenant); + const key = getStringValue(record?.id) || getStringValue(record?.slug); + if (record && key) tenants.set(key, record); + } + } + + const tenantList = [...tenants.values()]; + const primaryTenant = + tenantList.find((tenant) => tenant.isPrimary === true) ?? + tenantList.find((tenant) => tenant.representative === true) ?? + tenantList[0]; + const ancestors = Array.isArray(primaryTenant?.ancestors) ? + primaryTenant.ancestors + .map((ancestor) => asRecord(ancestor)) + .filter((ancestor): ancestor is Record => ancestor !== null) + : []; + const companyAffiliation = ancestors.find( + (ancestor) => getStringValue(ancestor.type) === 'COMPANY', + ); + + return { + requester_id: readClaim('sub', 'user_id', 'id'), + requester_uuid: readClaim('sub', 'user_id', 'id'), + requester_department: + readClaim('department') || getStringValue(primaryTenant?.name), + requester_affiliation: + getStringValue(companyAffiliation?.name) || + readClaim('affiliationType', 'affiliation_type') || + getStringValue(primaryTenant?.name), + requester_position: + readClaim('position', 'jobTitle', 'job_title') || + getStringValue(primaryTenant?.position) || + getStringValue(primaryTenant?.jobTitle) || + readClaim('grade') || + getStringValue(primaryTenant?.grade), + requester_grade: readClaim('grade') || getStringValue(primaryTenant?.grade), + requester_employee_id: readClaim( + 'employee_id', + 'employeeId', + 'employee_number', + 'employeeNumber', + ), + }; +}; const MAX_ATTACHMENT_COUNT = 10; const parseMultipartBody = (req: NextApiRequest) => @@ -133,13 +212,18 @@ const handler = createNextApiHandler({ } try { + const ssoClaims = getSupportSsoClaims(req); + const requesterFields = ssoClaims ? { + requester_sso_claims: JSON.stringify(ssoClaims), + ...getSsoRequesterFields(ssoClaims), + } : {}; let response: Response; if (contentType.startsWith('multipart/form-data')) { const { fields, files } = await parseMultipartBody(req); response = await forwardMultipart( workspaceCode, - fields, + { ...fields, ...requesterFields }, files, getSupportAuthHeaders(req), ); @@ -151,6 +235,16 @@ const handler = createNextApiHandler({ ); } + let requestBody = Buffer.concat(chunks); + if (Object.keys(requesterFields).length > 0) { + try { + const parsed = JSON.parse(requestBody.toString('utf8')) as Record; + requestBody = Buffer.from(JSON.stringify({ ...parsed, ...requesterFields })); + } catch { + // Keep the original body when the client sent non-JSON content. + } + } + response = await fetch( supportExternalUrl( `/workspaces/${encodeURIComponent(workspaceCode)}/tickets`, @@ -161,7 +255,7 @@ const handler = createNextApiHandler({ 'Content-Type': 'application/json', ...getSupportAuthHeaders(req), }, - body: Buffer.concat(chunks), + body: requestBody, signal: AbortSignal.timeout(60000), }, ); diff --git a/apps/web/src/pages/api/support/workspaces/[workspaceCode]/tickets.ts b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/tickets.ts index b219f23..75a79a1 100644 --- a/apps/web/src/pages/api/support/workspaces/[workspaceCode]/tickets.ts +++ b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/tickets.ts @@ -33,6 +33,7 @@ import { import { getSupportAuthHeaders, getSupportPrincipal, + getSupportSsoClaims, } from '@/server/support-auth'; import { supportExternalUrl } from '@/server/support-external'; import type { SupportPrincipal } from '@/server/support-auth'; @@ -145,13 +146,17 @@ const isUploadSizeError = (error: unknown) => { ); }; -const toAbcRequesterFields = (principal: SupportPrincipal) => ({ +const toAbcRequesterFields = ( + principal: SupportPrincipal, + ssoClaims: Record | null, +) => ({ requester_id: principal.user_id, requester_tenant_id: principal.tenant_id, requester_email: principal.email ?? '', requester_name: principal.name ?? '', requester_department: principal.department ?? '', requester_phone_number: principal.phone_number ?? '', + requester_sso_claims: ssoClaims ? JSON.stringify(ssoClaims) : '', }); const getFeedbackString = (feedback: Record, key: string) => @@ -665,7 +670,10 @@ const handler = createNextApiHandler({ message: '로그인 세션을 확인할 수 없습니다.', }); } - const requesterFields = toAbcRequesterFields(principal); + const requesterFields = toAbcRequesterFields( + principal, + getSupportSsoClaims(req), + ); try { let feedbackId: number; diff --git a/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx b/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx index 470d207..84d5790 100644 --- a/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx +++ b/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx @@ -657,19 +657,27 @@ const SupportDetailPage: NextPageWithLayout = () => {
이름: - {ticket.requester_name ?? '미등록'} + {ticket.requester_name || '미등록'}
이메일: - {ticket.requester_email ?? '미등록'} + {ticket.requester_email || '미등록'}
전화번호: - {ticket.requester_phone_number ?? ticket.requester_contact} + {ticket.requester_phone_number || ticket.requester_contact || '미등록'}
부서: - {ticket.requester_department ?? '미등록'} + {ticket.requester_department || '미등록'} +
+
+ 소속: + {ticket.requester_affiliation || '미등록'} +
+
+ 직책: + {ticket.requester_position || ticket.requester_grade || '미등록'}
diff --git a/apps/web/src/pages/support/[workspaceCode]/list.tsx b/apps/web/src/pages/support/[workspaceCode]/list.tsx index ad61d4b..106ebfd 100644 --- a/apps/web/src/pages/support/[workspaceCode]/list.tsx +++ b/apps/web/src/pages/support/[workspaceCode]/list.tsx @@ -43,6 +43,9 @@ type SortKey = | 'status'; type SortDirection = 'asc' | 'desc'; +const getDisplayTicketId = (ticket: SupportTicketRecord) => + ticket.display_id ?? ticket.ticket_id; + const SupportListPage: NextPageWithLayout = () => { const router = useRouter(); const workspaceCode = @@ -193,7 +196,7 @@ const SupportListPage: NextPageWithLayout = () => { const getValue = (ticket: SupportTicketRecord) => { switch (sortKey) { case 'ticket_id': - return ticket.ticket_id; + return getDisplayTicketId(ticket); case 'category': return getSupportCategoryLabel(ticket.category_code); case 'requester': @@ -402,7 +405,7 @@ const SupportListPage: NextPageWithLayout = () => { role="link" > - {ticket.ticket_id} + {getDisplayTicketId(ticket)} {getSupportCategoryLabel(ticket.category_code)} diff --git a/apps/web/src/pages/support/[workspaceCode]/new.tsx b/apps/web/src/pages/support/[workspaceCode]/new.tsx index 3bf0600..1627c68 100644 --- a/apps/web/src/pages/support/[workspaceCode]/new.tsx +++ b/apps/web/src/pages/support/[workspaceCode]/new.tsx @@ -35,6 +35,7 @@ import type { interface TicketCreateResponse { ticket_id: number; + display_id?: number; workspace_code: string; status_code: string; feedback_status: string; @@ -346,7 +347,7 @@ const SupportNewPage: NextPageWithLayout = () => { } const created = data as TicketCreateResponse; - setCreatedTicketId(created.ticket_id); + setCreatedTicketId(created.display_id ?? created.ticket_id); setSubmitMessage( `등록이 완료되었습니다. 첨부파일 ${attachments.length}개가 포함되었습니다.`, ); diff --git a/apps/web/src/server/local-sso.ts b/apps/web/src/server/local-sso.ts index b1e529f..4c40f6a 100644 --- a/apps/web/src/server/local-sso.ts +++ b/apps/web/src/server/local-sso.ts @@ -4,6 +4,11 @@ import type { NextApiRequest, NextApiResponse } from 'next'; const STATE_COOKIE = 'baron_sso_state'; const RETURN_COOKIE = 'baron_sso_return_to'; const SESSION_COOKIE = 'jwt'; +const DEBUG_TENANTS_COOKIE = 'baron_sso_debug_tenants'; +const DEBUG_TENANTS_COOKIE_COUNT = `${DEBUG_TENANTS_COOKIE}_count`; +const SSO_TOKEN_CLAIMS_COOKIE = 'baron_sso_token_claims'; +const SSO_TOKEN_CLAIMS_COOKIE_COUNT = `${SSO_TOKEN_CLAIMS_COOKIE}_count`; +const DEBUG_COOKIE_CHUNK_SIZE = 3000; const SESSION_MAX_AGE = 60 * 60; interface OidcUserInfo { @@ -14,24 +19,248 @@ interface OidcUserInfo { name?: unknown; department?: unknown; phone_number?: unknown; + phone?: unknown; + phones?: unknown; + profile?: unknown; + sessionAuthenticatedAt?: unknown; tenant_id?: unknown; tenantId?: unknown; tenant_ids?: unknown; tenantIds?: unknown; + tenants?: unknown; + tenantSlug?: unknown; + tenant?: unknown; + joinedTenants?: unknown; + affiliationType?: unknown; + grade?: unknown; + position?: unknown; + jobTitle?: unknown; } +interface SsoOrgMember { + id?: unknown; + email?: unknown; + name?: unknown; + phone?: unknown; + department?: unknown; + grade?: unknown; + position?: unknown; + jobTitle?: unknown; +} + +type SsoValueShape = + | string + | { type: 'array'; length: number; items: SsoValueShape[] } + | { type: 'object'; keys: Record }; + +const getSsoValueShape = (value: unknown, depth = 0): SsoValueShape => { + if (depth >= 4) return typeof value; + + if (Array.isArray(value)) { + const itemShapes = value + .slice(0, 5) + .map((item) => getSsoValueShape(item, depth + 1)); + const uniqueShapes = itemShapes.filter( + (shape, index, shapes) => + shapes.findIndex((candidate) => JSON.stringify(candidate) === JSON.stringify(shape)) === + index, + ); + + return { + type: 'array', + length: value.length, + items: uniqueShapes, + }; + } + + if (value && typeof value === 'object') { + const record = value as Record; + return { + type: 'object', + keys: Object.fromEntries( + Object.entries(record).map(([key, nestedValue]) => [ + key, + getSsoValueShape(nestedValue, depth + 1), + ]), + ), + }; + } + + return typeof value; +}; + +const logSsoUserInfoShape = (data: OidcUserInfo) => { + if (configured('SSO_DEBUG_USERINFO') !== 'true') return; + + console.info('[SSO userinfo] response shape:', JSON.stringify(getSsoValueShape(data))); +}; + const asString = (value: unknown) => typeof value === 'string' && value.trim() ? value.trim() : null; +// OAuth access tokens are not always JWTs. This is decode-only debug support; +// it never logs or stores the original token value. +const decodeJwtPayload = (token: string | undefined): Record | null => { + if (!token) return null; + + const parts = token.split('.'); + if (parts.length < 2) return null; + + try { + const parsed = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + return parsed && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + return null; + } +}; + const asStringArray = (value: unknown) => Array.isArray(value) ? value.filter((item): item is string => Boolean(asString(item))) : []; +const getPhoneNumber = (info: OidcUserInfo) => { + const profile = + info.profile && typeof info.profile === 'object' ? + (info.profile as Record) + : null; + const sources = [ + info.phone_number, + info.phone, + info.phones, + profile?.phone_number, + profile?.phone, + profile?.phones, + ]; + + const findPhone = (value: unknown): string | null => { + const direct = asString(value); + if (direct) return direct; + + if (Array.isArray(value)) { + for (const item of value) { + const phone = findPhone(item); + if (phone) return phone; + } + return null; + } + + if (value && typeof value === 'object') { + const record = value as Record; + for (const key of [ + 'phone_number', + 'phoneNumber', + 'phone', + 'number', + 'value', + 'mobile', + 'telephone', + 'tel', + ]) { + const phone = findPhone(record[key]); + if (phone) return phone; + } + } + + return null; + }; + + for (const source of sources) { + const phone = findPhone(source); + if (phone) return phone; + } + + return null; +}; + const configured = (name: string) => process.env[name]?.trim() ?? ''; const getIssuer = () => configured('SSO_ISSUER') || 'https://sso.hmac.kr/oidc'; const getEndpoint = (name: string, fallback: string) => configured(name) || fallback; +const asRecord = (value: unknown): Record | null => + value && typeof value === 'object' && !Array.isArray(value) ? + (value as Record) + : null; + +const getSsoOrgMember = async ( + accessToken: string, + info: OidcUserInfo, +): Promise => { + const currentTenant = asRecord(info.tenant); + const joinedTenants = Array.isArray(info.joinedTenants) ? info.joinedTenants : []; + const firstJoinedTenant = asRecord(joinedTenants[0]); + const tenantSlug = + asString(info.tenantSlug) ?? + asString(currentTenant?.slug) ?? + asString(firstJoinedTenant?.slug); + const tenantId = + asString(info.tenant_id) ?? + asString(info.tenantId) ?? + asString(currentTenant?.id) ?? + asString(firstJoinedTenant?.id); + + if (!tenantSlug && !tenantId) return null; + + const endpoint = new URL( + getEndpoint( + 'SSO_ORG_CONTEXT_ENDPOINT', + 'https://sso.hmac.kr/api/v1/integrations/org-context', + ), + ); + if (tenantSlug) endpoint.searchParams.set('tenantSlug', tenantSlug); + else if (tenantId) endpoint.searchParams.set('tenantId', tenantId); + endpoint.searchParams.set('exposure', 'members'); + + const response = await fetch(endpoint, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { + tree?: { members?: unknown }; + tenants?: unknown; + }; + const members: Record[] = []; + const treeMembers = Array.isArray(data.tree?.members) ? data.tree.members : []; + const tenantMembers = Array.isArray(data.tenants) ? + data.tenants.flatMap((tenant) => { + const record = asRecord(tenant); + return Array.isArray(record?.members) ? record.members : []; + }) + : []; + + for (const candidate of [...treeMembers, ...tenantMembers]) { + const member = asRecord(candidate); + if (member) members.push(member); + } + + const subject = asString(info.id) ?? asString(info.sub) ?? asString(info.user_id); + const email = asString(info.email); + return ( + members.find((member) => { + const memberId = asString(member.id); + const memberEmail = asString(member.email); + return (subject && memberId === subject) || (email && memberEmail === email); + }) ?? null + ); +}; + +const enrichSsoUserInfo = async (accessToken: string, info: OidcUserInfo) => { + const member = await getSsoOrgMember(accessToken, info).catch(() => null); + if (!member) return info; + + return { + ...info, + email: asString(info.email) ?? asString(member.email), + name: asString(info.name) ?? asString(member.name), + phone: asString(info.phone) ?? asString(member.phone), + department: asString(info.department) ?? asString(member.department), + grade: asString(info.grade) ?? asString(member.grade), + position: asString(info.position) ?? asString(member.position), + jobTitle: asString(info.jobTitle) ?? asString(member.jobTitle), + }; +}; + export const getRedirectUri = (req: NextApiRequest) => { const configuredUri = configured('SSO_REDIRECT_URI'); if (configuredUri) return configuredUri; @@ -56,6 +285,132 @@ const makeCookie = ( return parts.join('; '); }; +const makeJsonCookies = ( + name: string, + value: unknown, + secure: boolean, + httpOnly: boolean, +) => { + const serialized = JSON.stringify(value); + const encoded = encodeURIComponent(serialized); + if (encoded.length <= DEBUG_COOKIE_CHUNK_SIZE) { + return [ + makeCookie(`${name}_count`, '', { maxAge: 0, httpOnly, secure }), + makeCookie(name, serialized, { maxAge: 60 * 60, httpOnly, secure }), + ]; + } + + const chunks: string[] = []; + let currentChunk = ''; + for (const character of serialized) { + const nextChunk = `${currentChunk}${character}`; + if ( + currentChunk && + encodeURIComponent(nextChunk).length > DEBUG_COOKIE_CHUNK_SIZE + ) { + chunks.push(currentChunk); + currentChunk = character; + } else { + currentChunk = nextChunk; + } + } + if (currentChunk) chunks.push(currentChunk); + + const cookies = [ + makeCookie(name, '', { maxAge: 0, httpOnly, secure }), + makeCookie(`${name}_count`, String(chunks.length), { + maxAge: 60 * 60, + httpOnly, + secure, + }), + ]; + for (const [index, chunk] of chunks.entries()) { + cookies.push( + makeCookie( + `${name}_${index}`, + chunk, + { maxAge: 60 * 60, httpOnly, secure }, + ), + ); + } + return cookies; +}; + +const makeDebugCookies = (value: unknown, secure: boolean) => + makeJsonCookies(DEBUG_TENANTS_COOKIE, value, secure, false); + +const getSessionAccessToken = (req: NextApiRequest) => { + const authorization = req.headers.authorization; + if (authorization?.startsWith('Bearer ')) { + return asString(authorization.slice('Bearer '.length)); + } + + const rawCookie = req.cookies[SESSION_COOKIE]; + if (!rawCookie) return null; + + try { + const parsed = JSON.parse(decodeURIComponent(rawCookie)) as { accessToken?: unknown }; + return asString(parsed.accessToken); + } catch { + return null; + } +}; + +export const getSsoDebugInfo = async (req: NextApiRequest) => { + if (configured('SSO_DEBUG_USERINFO') !== 'true') return null; + + const rawDebugCookie = req.cookies[DEBUG_TENANTS_COOKIE]; + if (rawDebugCookie) { + try { + return JSON.parse(decodeURIComponent(rawDebugCookie)) as unknown; + } catch { + // Fall through to the live lookup for malformed or expired debug data. + } + } + + const accessToken = getSessionAccessToken(req); + if (!accessToken) return null; + + try { + const userInfo = await enrichSsoUserInfo(accessToken, await getUserInfo(accessToken)); + return { + source: 'userinfo + org-context', + rawUserInfoUser: { + id: userInfo.id ?? userInfo.sub ?? userInfo.user_id ?? null, + email: userInfo.email ?? null, + name: userInfo.name ?? null, + phone: userInfo.phone ?? userInfo.phone_number ?? null, + phones: userInfo.phones ?? null, + sessionAuthenticatedAt: userInfo.sessionAuthenticatedAt ?? null, + department: userInfo.department ?? null, + affiliationType: userInfo.affiliationType ?? null, + tenantSlug: userInfo.tenantSlug ?? null, + }, + rawUserInfoTenants: { + tenant: userInfo.tenant ?? null, + joinedTenants: Array.isArray(userInfo.joinedTenants) ? userInfo.joinedTenants : [], + }, + user: { + id: asString(userInfo.id) ?? asString(userInfo.sub) ?? asString(userInfo.user_id), + name: asString(userInfo.name), + }, + tenant: { + id: asString(asRecord(userInfo.tenant)?.id), + name: asString(asRecord(userInfo.tenant)?.name), + slug: asString(userInfo.tenantSlug) ?? asString(asRecord(userInfo.tenant)?.slug), + }, + member: { + department: asString(userInfo.department), + grade: asString(userInfo.grade), + position: asString(userInfo.position), + jobTitle: asString(userInfo.jobTitle), + }, + }; + } catch { + return null; + } +}; + const isSecureRequest = (req: NextApiRequest) => { const forwardedProto = req.headers['x-forwarded-proto']; return ( @@ -87,7 +442,10 @@ export const buildLoginUrl = (req: NextApiRequest) => { url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', clientId); url.searchParams.set('redirect_uri', getRedirectUri(req)); - url.searchParams.set('scope', configured('SSO_SCOPE') || 'openid profile email'); + url.searchParams.set( + 'scope', + configured('SSO_SCOPE') || 'openid profile email tenants', + ); url.searchParams.set('state', state); return { state, url: url.toString() }; }; @@ -127,6 +485,8 @@ const exchangeCode = async (req: NextApiRequest, code: string) => { const data = (await response.json()) as { access_token?: unknown; id_token?: unknown; + scope?: unknown; + token_type?: unknown; error_description?: unknown; }; if (!response.ok || typeof data.access_token !== 'string') { @@ -137,6 +497,8 @@ const exchangeCode = async (req: NextApiRequest, code: string) => { return { accessToken: data.access_token, idToken: asString(data.id_token) ?? undefined, + scope: asString(data.scope) ?? undefined, + tokenType: asString(data.token_type) ?? undefined, }; }; @@ -147,6 +509,7 @@ const getUserInfo = async (accessToken: string): Promise => { ); const data = (await response.json()) as OidcUserInfo; if (!response.ok) throw new Error('BARON-SSO 사용자 정보를 가져오지 못했습니다.'); + logSsoUserInfoShape(data); return data; }; @@ -167,6 +530,8 @@ const createSupportSession = (info: OidcUserInfo) => { configured('SUPPORT_TENANT_ID') || asString(info.tenant_id) || asString(info.tenantId) || + asString(asRecord(info.tenant)?.id) || + asString(asRecord(Array.isArray(info.joinedTenants) ? info.joinedTenants[0] : null)?.id) || asStringArray(info.tenant_ids ?? info.tenantIds)[0] || ''; if (!subject || !tenantId) { @@ -186,7 +551,14 @@ const createSupportSession = (info: OidcUserInfo) => { email: asString(info.email), name: asString(info.name), department: asString(info.department), - phone_number: asString(info.phone_number), + phone_number: getPhoneNumber(info), + affiliation_type: asString(info.affiliationType), + tenant_slug: + asString(info.tenantSlug) ?? asString(asRecord(info.tenant)?.slug), + tenant_name: asString(asRecord(info.tenant)?.name), + grade: asString(info.grade), + position: asString(info.position), + job_title: asString(info.jobTitle), type: 'GENERAL', iat: now, exp: now + SESSION_MAX_AGE, @@ -202,9 +574,28 @@ export const completeLogin = async (req: NextApiRequest, res: NextApiResponse) = throw new Error('BARON-SSO state가 유효하지 않습니다. 로그인부터 다시 시도하세요.'); } const tokens = await exchangeCode(req, code); - const userInfo = await getUserInfo(tokens.accessToken); + const idTokenClaims = decodeJwtPayload(tokens.idToken); + const accessTokenClaims = decodeJwtPayload(tokens.accessToken); + const ssoTokenClaims: Record = { + requested_scopes: configured('SSO_SCOPE') || 'openid profile email tenants', + token_response_scope: tokens.scope ?? null, + token_type: tokens.tokenType ?? null, + id_token: idTokenClaims, + access_token: accessTokenClaims, + }; + const rawUserInfo = await getUserInfo(tokens.accessToken); + const userInfo = await enrichSsoUserInfo(tokens.accessToken, rawUserInfo); + ssoTokenClaims.userinfo = userInfo as Record; const sessionToken = createSupportSession(userInfo); const secure = isSecureRequest(req); + const debugOrgContext = { + source: 'oauth token claims (decode-only)', + requestedScopes: configured('SSO_SCOPE') || 'openid profile email tenants', + tokenResponseScope: tokens.scope ?? null, + tokenType: tokens.tokenType ?? null, + idTokenClaims, + accessTokenClaims, + }; res.setHeader('Set-Cookie', [ makeCookie(SESSION_COOKIE, JSON.stringify({ accessToken: sessionToken, refreshToken: '' }), { maxAge: SESSION_MAX_AGE, @@ -213,11 +604,23 @@ export const completeLogin = async (req: NextApiRequest, res: NextApiResponse) = }), makeCookie(STATE_COOKIE, '', { maxAge: 0, httpOnly: true, secure }), makeCookie(RETURN_COOKIE, '', { maxAge: 0, httpOnly: true, secure }), + ...makeJsonCookies(SSO_TOKEN_CLAIMS_COOKIE, ssoTokenClaims, secure, true), + ...(configured('SSO_DEBUG_USERINFO') === 'true' ? + makeDebugCookies(debugOrgContext, secure) + : []), ]); return safeReturnTo(req.cookies[RETURN_COOKIE]); }; export const clearSession = (req: NextApiRequest, res: NextApiResponse) => { const secure = isSecureRequest(req); - res.setHeader('Set-Cookie', makeCookie(SESSION_COOKIE, '', { maxAge: 0, httpOnly: true, secure })); + res.setHeader('Set-Cookie', [ + makeCookie(SESSION_COOKIE, '', { maxAge: 0, httpOnly: true, secure }), + makeCookie(SSO_TOKEN_CLAIMS_COOKIE, '', { maxAge: 0, httpOnly: true, secure }), + makeCookie(SSO_TOKEN_CLAIMS_COOKIE_COUNT, '', { + maxAge: 0, + httpOnly: true, + secure, + }), + ]); }; diff --git a/apps/web/src/server/support-abc.ts b/apps/web/src/server/support-abc.ts index 595cfea..5b74ae8 100644 --- a/apps/web/src/server/support-abc.ts +++ b/apps/web/src/server/support-abc.ts @@ -24,6 +24,7 @@ const SUPPORT_REQUESTER_METADATA_KEYS = [ 'requester_name', 'requester_department', 'requester_phone_number', + 'requester_sso_claims', 'requester_contact', 'is_secret', ] as const; diff --git a/apps/web/src/server/support-auth.ts b/apps/web/src/server/support-auth.ts index 2187f7e..1374b82 100644 --- a/apps/web/src/server/support-auth.ts +++ b/apps/web/src/server/support-auth.ts @@ -18,6 +18,8 @@ import type { NextApiRequest } from 'next'; import { supportExternalUrl } from './support-external'; type StoredJwt = { accessToken?: string }; +const SSO_TOKEN_CLAIMS_COOKIE = 'baron_sso_token_claims'; +const SSO_TOKEN_CLAIMS_COOKIE_COUNT = `${SSO_TOKEN_CLAIMS_COOKIE}_count`; export interface SupportPrincipal { user_id: string; @@ -31,6 +33,32 @@ export interface SupportPrincipal { roles?: string[]; } +const decodeCookieValue = (value: string) => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +export const getSupportSsoClaims = (req: NextApiRequest) => { + const count = Number(req.cookies[SSO_TOKEN_CLAIMS_COOKIE_COUNT] ?? 0); + const rawValue = + count > 0 ? + Array.from({ length: count }, (_, index) => + req.cookies[`${SSO_TOKEN_CLAIMS_COOKIE}_${index}`] ?? '', + ).join('') + : req.cookies[SSO_TOKEN_CLAIMS_COOKIE]; + + if (!rawValue) return null; + + try { + return JSON.parse(decodeCookieValue(rawValue)) as Record; + } catch { + return null; + } +}; + const SUPPORT_MANAGER_ROLES = new Set([ 'SYSTEM_ADMIN', 'SUPER_ADMIN', diff --git a/apps/web/src/server/support-types.ts b/apps/web/src/server/support-types.ts index 1a5df64..3cf50c4 100644 --- a/apps/web/src/server/support-types.ts +++ b/apps/web/src/server/support-types.ts @@ -79,6 +79,8 @@ export interface SupportAttachmentRecord { export interface SupportTicketRecord extends SupportFeedbackAutomationMetadata { ticket_id: number; + /** Project-scoped display number; ticket_id remains the API identifier. */ + display_id?: number; workspace_code: string; workspace_name: string; title: string; @@ -89,6 +91,9 @@ export interface SupportTicketRecord extends SupportFeedbackAutomationMetadata { requester_email?: string | null; requester_name?: string | null; requester_department?: string | null; + requester_affiliation?: string | null; + requester_position?: string | null; + requester_grade?: string | null; requester_phone_number?: string | null; category_code: string; ticket_type: string; diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 9e1c247..e5a0f96 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -22,7 +22,11 @@ services: SSO_AUTHORIZATION_ENDPOINT: ${SSO_AUTHORIZATION_ENDPOINT:-https://sso.hmac.kr/oidc/oauth2/auth} SSO_TOKEN_ENDPOINT: ${SSO_TOKEN_ENDPOINT:-https://sso.hmac.kr/oidc/oauth2/token} SSO_USERINFO_ENDPOINT: ${SSO_USERINFO_ENDPOINT:-https://sso.hmac.kr/oidc/userinfo} - SSO_SCOPE: ${SSO_SCOPE:-openid profile email} + SSO_ORG_CONTEXT_ENDPOINT: ${SSO_ORG_CONTEXT_ENDPOINT:-https://sso.hmac.kr/api/v1/integrations/org-context} + SSO_SCOPE: ${SSO_SCOPE:-openid profile email tenants} + # Set to true temporarily to log only the SSO userinfo response shape + # (field names/types, never the actual personal-information values). + SSO_DEBUG_USERINFO: ${SSO_DEBUG_USERINFO:-false} # These are server-only values. Do not prefix them with NEXT_PUBLIC_ and # do not put them in the browser build. SSO_CLIENT_ID: ${SSO_CLIENT_ID:-}