fix: 첨부파일 오류 개선

This commit is contained in:
root
2026-09-11 17:37:37 +09:00
parent 4a79555d16
commit 2c32ae608f
16 changed files with 797 additions and 21 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+6 -1
View File
@@ -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)
+1 -1
View File
@@ -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=
+42
View File
@@ -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();
@@ -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);
}
@@ -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<string, unknown>, key: string) =>
typeof feedback[key] === 'string' ? feedback[key] : '';
const asRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === 'object' && !Array.isArray(value) ?
(value as Record<string, unknown>)
: null;
const getStringValue = (value: unknown) =>
typeof value === 'string' && value.trim() ? value.trim() : '';
const getRequesterSsoFields = (feedback: Record<string, unknown>) => {
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<string, unknown> => source !== null);
const profileSources = tokenSources
.flatMap((source) => [source, asRecord(source.profile)])
.filter((source): source is Record<string, unknown> => 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<string, Record<string, unknown>>();
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<string, unknown> => 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<string, unknown>,
sessionClaims?: Record<string, unknown> | 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<string, unknown>, 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<string, unknown>, sessionClaims)
: data,
);
} catch {
return res
.status(503)
@@ -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<string, unknown> | null =>
value && typeof value === 'object' && !Array.isArray(value) ?
(value as Record<string, unknown>)
: null;
const getStringValue = (value: unknown) =>
typeof value === 'string' && value.trim() ? value.trim() : '';
const getSsoRequesterFields = (claims: Record<string, unknown>) => {
const tokenSources = [
claims,
asRecord(claims.id_token),
asRecord(claims.access_token),
asRecord(claims.userinfo),
asRecord(claims.user_info),
].filter((source): source is Record<string, unknown> => source !== null);
const profileSources = tokenSources
.flatMap((source) => [source, asRecord(source.profile)])
.filter((source): source is Record<string, unknown> => 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<string, Record<string, unknown>>();
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<string, unknown> => 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<string, unknown>;
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),
},
);
@@ -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<string, unknown> | 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<string, unknown>, key: string) =>
@@ -665,7 +670,10 @@ const handler = createNextApiHandler({
message: '로그인 세션을 확인할 수 없습니다.',
});
}
const requesterFields = toAbcRequesterFields(principal);
const requesterFields = toAbcRequesterFields(
principal,
getSupportSsoClaims(req),
);
try {
let feedbackId: number;
@@ -657,19 +657,27 @@ const SupportDetailPage: NextPageWithLayout = () => {
<div className="mt-3 grid gap-2 text-sm text-[#555] sm:grid-cols-2">
<div>
<span className="font-medium text-[#333]">: </span>
{ticket.requester_name ?? '미등록'}
{ticket.requester_name || '미등록'}
</div>
<div>
<span className="font-medium text-[#333]">: </span>
{ticket.requester_email ?? '미등록'}
{ticket.requester_email || '미등록'}
</div>
<div>
<span className="font-medium text-[#333]">: </span>
{ticket.requester_phone_number ?? ticket.requester_contact}
{ticket.requester_phone_number || ticket.requester_contact || '미등록'}
</div>
<div>
<span className="font-medium text-[#333]">: </span>
{ticket.requester_department ?? '미등록'}
{ticket.requester_department || '미등록'}
</div>
<div>
<span className="font-medium text-[#333]">: </span>
{ticket.requester_affiliation || '미등록'}
</div>
<div>
<span className="font-medium text-[#333]">: </span>
{ticket.requester_position || ticket.requester_grade || '미등록'}
</div>
</div>
</div>
@@ -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"
>
<td className="px-4 py-4 align-top">
{ticket.ticket_id}
{getDisplayTicketId(ticket)}
</td>
<td className="px-4 py-4 align-top">
{getSupportCategoryLabel(ticket.category_code)}
@@ -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}개가 포함되었습니다.`,
);
+407 -4
View File
@@ -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<string, SsoValueShape> };
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<string, unknown>;
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<string, unknown> | 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<string, unknown>) : 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<string, unknown>)
: 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<string, unknown>;
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<string, unknown> | null =>
value && typeof value === 'object' && !Array.isArray(value) ?
(value as Record<string, unknown>)
: null;
const getSsoOrgMember = async (
accessToken: string,
info: OidcUserInfo,
): Promise<SsoOrgMember | null> => {
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<string, unknown>[] = [];
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<OidcUserInfo> => {
);
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<string, unknown> = {
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<string, unknown>;
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,
}),
]);
};
+1
View File
@@ -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;
+28
View File
@@ -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<string, unknown>;
} catch {
return null;
}
};
const SUPPORT_MANAGER_ROLES = new Set([
'SYSTEM_ADMIN',
'SUPER_ADMIN',
+5
View File
@@ -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;
+5 -1
View File
@@ -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:-}