fix: 첨부파일 오류 개선
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -4,6 +4,7 @@ from typing import Annotated, cast
|
|||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from app.core.auth import SsoPrincipal, get_principal
|
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"])
|
router = APIRouter(prefix="/workspaces", tags=["tickets"])
|
||||||
service = TicketService()
|
service = TicketService()
|
||||||
access_service = AccessService()
|
access_service = AccessService()
|
||||||
|
MAX_ATTACHMENT_SIZE_BYTES = 30 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def can_manage_ticket_comments(
|
def can_manage_ticket_comments(
|
||||||
@@ -250,7 +252,10 @@ async def create_ticket(
|
|||||||
content_type = request.headers.get("content-type", "")
|
content_type = request.headers.get("content-type", "")
|
||||||
|
|
||||||
if content_type.startswith("multipart/form-data"):
|
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_value = form.get("extra_fields")
|
||||||
extra_fields = (
|
extra_fields = (
|
||||||
json.loads(extra_fields_value)
|
json.loads(extra_fields_value)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SSO_ISSUER=https://sso.hmac.kr/oidc
|
|||||||
SSO_AUTHORIZATION_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/auth
|
SSO_AUTHORIZATION_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/auth
|
||||||
SSO_TOKEN_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/token
|
SSO_TOKEN_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/token
|
||||||
SSO_USERINFO_ENDPOINT=https://sso.hmac.kr/oidc/userinfo
|
SSO_USERINFO_ENDPOINT=https://sso.hmac.kr/oidc/userinfo
|
||||||
SSO_SCOPE=openid profile email
|
SSO_SCOPE=openid profile email tenants
|
||||||
SSO_REDIRECT_URI=
|
SSO_REDIRECT_URI=
|
||||||
SSO_CLIENT_ID=
|
SSO_CLIENT_ID=
|
||||||
SSO_CLIENT_SECRET=
|
SSO_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -61,6 +61,48 @@ function App({ Component, pageProps }: AppPropsWithLayout) {
|
|||||||
const { setUser, user, randomId } = useUserStore();
|
const { setUser, user, randomId } = useUserStore();
|
||||||
const router = useRouter();
|
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(() => {
|
useEffect(() => {
|
||||||
if (user) return;
|
if (user) return;
|
||||||
void setUser();
|
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 {
|
import {
|
||||||
getSupportAuthHeaders,
|
getSupportAuthHeaders,
|
||||||
getSupportPrincipal,
|
getSupportPrincipal,
|
||||||
|
getSupportSsoClaims,
|
||||||
isSupportAdmin,
|
isSupportAdmin,
|
||||||
} from '@/server/support-auth';
|
} from '@/server/support-auth';
|
||||||
import { supportExternalUrl } from '@/server/support-external';
|
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) =>
|
const getFeedbackString = (feedback: Record<string, unknown>, key: string) =>
|
||||||
typeof feedback[key] === 'string' ? feedback[key] : '';
|
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 getFeedbackFlag = (feedback: Record<string, unknown>, key: string) => {
|
||||||
const value = feedback[key];
|
const value = feedback[key];
|
||||||
return (
|
return (
|
||||||
@@ -176,6 +308,7 @@ const synthesizeSupportTicketFromAbc = async (
|
|||||||
currentIssue && typeof currentIssue.status === 'string' ?
|
currentIssue && typeof currentIssue.status === 'string' ?
|
||||||
currentIssue.status
|
currentIssue.status
|
||||||
: null;
|
: null;
|
||||||
|
const requesterSsoFields = getRequesterSsoFields(feedback);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ticket_id: ticketId,
|
ticket_id: ticketId,
|
||||||
@@ -189,6 +322,15 @@ const synthesizeSupportTicketFromAbc = async (
|
|||||||
requester_email: getFeedbackString(feedback, 'requester_email'),
|
requester_email: getFeedbackString(feedback, 'requester_email'),
|
||||||
requester_name: getFeedbackString(feedback, 'requester_name'),
|
requester_name: getFeedbackString(feedback, 'requester_name'),
|
||||||
requester_department: getFeedbackString(feedback, 'requester_department'),
|
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:
|
requester_phone_number:
|
||||||
getFeedbackString(feedback, 'requester_phone_number') ||
|
getFeedbackString(feedback, 'requester_phone_number') ||
|
||||||
getFeedbackString(feedback, 'phone_number'),
|
getFeedbackString(feedback, 'phone_number'),
|
||||||
@@ -233,6 +375,7 @@ const synthesizeAbcTicketFromFeedback = async (
|
|||||||
: null;
|
: null;
|
||||||
const { title, description } = getAbcFeedbackContent(feedback);
|
const { title, description } = getAbcFeedbackContent(feedback);
|
||||||
const legacyRequester = getLegacyRequester(feedback);
|
const legacyRequester = getLegacyRequester(feedback);
|
||||||
|
const requesterSsoFields = getRequesterSsoFields(feedback);
|
||||||
const getString = (key: string) => {
|
const getString = (key: string) => {
|
||||||
const aliases =
|
const aliases =
|
||||||
key === 'ip_address' ? ['ip_address', 'IP']
|
key === 'ip_address' ? ['ip_address', 'IP']
|
||||||
@@ -269,6 +412,9 @@ const synthesizeAbcTicketFromFeedback = async (
|
|||||||
'requester_email',
|
'requester_email',
|
||||||
'requester_name',
|
'requester_name',
|
||||||
'requester_department',
|
'requester_department',
|
||||||
|
'requester_affiliation',
|
||||||
|
'requester_position',
|
||||||
|
'requester_grade',
|
||||||
'requester_phone_number',
|
'requester_phone_number',
|
||||||
'is_secret',
|
'is_secret',
|
||||||
]) {
|
]) {
|
||||||
@@ -288,6 +434,12 @@ const synthesizeAbcTicketFromFeedback = async (
|
|||||||
requester_email: getString('requester_email'),
|
requester_email: getString('requester_email'),
|
||||||
requester_name: getString('requester_name'),
|
requester_name: getString('requester_name'),
|
||||||
requester_department: getString('requester_department'),
|
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:
|
category_code:
|
||||||
getString('category_code') || getString('Category') || 'GENERAL',
|
getString('category_code') || getString('Category') || 'GENERAL',
|
||||||
ticket_type: 'GENERAL',
|
ticket_type: 'GENERAL',
|
||||||
@@ -307,7 +459,12 @@ const synthesizeAbcTicketFromFeedback = async (
|
|||||||
typeof feedback.updatedAt === 'string' ?
|
typeof feedback.updatedAt === 'string' ?
|
||||||
feedback.updatedAt
|
feedback.updatedAt
|
||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
extra_fields: extraFields,
|
extra_fields: {
|
||||||
|
...extraFields,
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(requesterSsoFields).filter(([, value]) => value),
|
||||||
|
),
|
||||||
|
},
|
||||||
attachments: getAbcFeedbackAttachments(feedback),
|
attachments: getAbcFeedbackAttachments(feedback),
|
||||||
activity: [],
|
activity: [],
|
||||||
};
|
};
|
||||||
@@ -334,7 +491,12 @@ const handler = createNextApiHandler({
|
|||||||
{ headers: getSupportAuthHeaders(req) },
|
{ headers: getSupportAuthHeaders(req) },
|
||||||
);
|
);
|
||||||
const data = (await response.json()) as unknown;
|
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 {
|
} catch {
|
||||||
return res
|
return res
|
||||||
.status(503)
|
.status(503)
|
||||||
|
|||||||
@@ -8,10 +8,89 @@ import type {
|
|||||||
} from 'formidable';
|
} from 'formidable';
|
||||||
|
|
||||||
import { createNextApiHandler } from '@/server/api-handler';
|
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';
|
import { supportExternalUrl } from '@/server/support-external';
|
||||||
|
|
||||||
const MAX_UPLOAD_SIZE_BYTES = 30 * 1024 * 1024;
|
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 MAX_ATTACHMENT_COUNT = 10;
|
||||||
|
|
||||||
const parseMultipartBody = (req: NextApiRequest) =>
|
const parseMultipartBody = (req: NextApiRequest) =>
|
||||||
@@ -133,13 +212,18 @@ const handler = createNextApiHandler({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const ssoClaims = getSupportSsoClaims(req);
|
||||||
|
const requesterFields = ssoClaims ? {
|
||||||
|
requester_sso_claims: JSON.stringify(ssoClaims),
|
||||||
|
...getSsoRequesterFields(ssoClaims),
|
||||||
|
} : {};
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
|
||||||
if (contentType.startsWith('multipart/form-data')) {
|
if (contentType.startsWith('multipart/form-data')) {
|
||||||
const { fields, files } = await parseMultipartBody(req);
|
const { fields, files } = await parseMultipartBody(req);
|
||||||
response = await forwardMultipart(
|
response = await forwardMultipart(
|
||||||
workspaceCode,
|
workspaceCode,
|
||||||
fields,
|
{ ...fields, ...requesterFields },
|
||||||
files,
|
files,
|
||||||
getSupportAuthHeaders(req),
|
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(
|
response = await fetch(
|
||||||
supportExternalUrl(
|
supportExternalUrl(
|
||||||
`/workspaces/${encodeURIComponent(workspaceCode)}/tickets`,
|
`/workspaces/${encodeURIComponent(workspaceCode)}/tickets`,
|
||||||
@@ -161,7 +255,7 @@ const handler = createNextApiHandler({
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...getSupportAuthHeaders(req),
|
...getSupportAuthHeaders(req),
|
||||||
},
|
},
|
||||||
body: Buffer.concat(chunks),
|
body: requestBody,
|
||||||
signal: AbortSignal.timeout(60000),
|
signal: AbortSignal.timeout(60000),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getSupportAuthHeaders,
|
getSupportAuthHeaders,
|
||||||
getSupportPrincipal,
|
getSupportPrincipal,
|
||||||
|
getSupportSsoClaims,
|
||||||
} from '@/server/support-auth';
|
} from '@/server/support-auth';
|
||||||
import { supportExternalUrl } from '@/server/support-external';
|
import { supportExternalUrl } from '@/server/support-external';
|
||||||
import type { SupportPrincipal } from '@/server/support-auth';
|
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_id: principal.user_id,
|
||||||
requester_tenant_id: principal.tenant_id,
|
requester_tenant_id: principal.tenant_id,
|
||||||
requester_email: principal.email ?? '',
|
requester_email: principal.email ?? '',
|
||||||
requester_name: principal.name ?? '',
|
requester_name: principal.name ?? '',
|
||||||
requester_department: principal.department ?? '',
|
requester_department: principal.department ?? '',
|
||||||
requester_phone_number: principal.phone_number ?? '',
|
requester_phone_number: principal.phone_number ?? '',
|
||||||
|
requester_sso_claims: ssoClaims ? JSON.stringify(ssoClaims) : '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const getFeedbackString = (feedback: Record<string, unknown>, key: string) =>
|
const getFeedbackString = (feedback: Record<string, unknown>, key: string) =>
|
||||||
@@ -665,7 +670,10 @@ const handler = createNextApiHandler({
|
|||||||
message: '로그인 세션을 확인할 수 없습니다.',
|
message: '로그인 세션을 확인할 수 없습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const requesterFields = toAbcRequesterFields(principal);
|
const requesterFields = toAbcRequesterFields(
|
||||||
|
principal,
|
||||||
|
getSupportSsoClaims(req),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let feedbackId: number;
|
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 className="mt-3 grid gap-2 text-sm text-[#555] sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-[#333]">이름: </span>
|
<span className="font-medium text-[#333]">이름: </span>
|
||||||
{ticket.requester_name ?? '미등록'}
|
{ticket.requester_name || '미등록'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-[#333]">이메일: </span>
|
<span className="font-medium text-[#333]">이메일: </span>
|
||||||
{ticket.requester_email ?? '미등록'}
|
{ticket.requester_email || '미등록'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-[#333]">전화번호: </span>
|
<span className="font-medium text-[#333]">전화번호: </span>
|
||||||
{ticket.requester_phone_number ?? ticket.requester_contact}
|
{ticket.requester_phone_number || ticket.requester_contact || '미등록'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-[#333]">부서: </span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ type SortKey =
|
|||||||
| 'status';
|
| 'status';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
const getDisplayTicketId = (ticket: SupportTicketRecord) =>
|
||||||
|
ticket.display_id ?? ticket.ticket_id;
|
||||||
|
|
||||||
const SupportListPage: NextPageWithLayout = () => {
|
const SupportListPage: NextPageWithLayout = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const workspaceCode =
|
const workspaceCode =
|
||||||
@@ -193,7 +196,7 @@ const SupportListPage: NextPageWithLayout = () => {
|
|||||||
const getValue = (ticket: SupportTicketRecord) => {
|
const getValue = (ticket: SupportTicketRecord) => {
|
||||||
switch (sortKey) {
|
switch (sortKey) {
|
||||||
case 'ticket_id':
|
case 'ticket_id':
|
||||||
return ticket.ticket_id;
|
return getDisplayTicketId(ticket);
|
||||||
case 'category':
|
case 'category':
|
||||||
return getSupportCategoryLabel(ticket.category_code);
|
return getSupportCategoryLabel(ticket.category_code);
|
||||||
case 'requester':
|
case 'requester':
|
||||||
@@ -402,7 +405,7 @@ const SupportListPage: NextPageWithLayout = () => {
|
|||||||
role="link"
|
role="link"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-4 align-top">
|
<td className="px-4 py-4 align-top">
|
||||||
{ticket.ticket_id}
|
{getDisplayTicketId(ticket)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4 align-top">
|
<td className="px-4 py-4 align-top">
|
||||||
{getSupportCategoryLabel(ticket.category_code)}
|
{getSupportCategoryLabel(ticket.category_code)}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type {
|
|||||||
|
|
||||||
interface TicketCreateResponse {
|
interface TicketCreateResponse {
|
||||||
ticket_id: number;
|
ticket_id: number;
|
||||||
|
display_id?: number;
|
||||||
workspace_code: string;
|
workspace_code: string;
|
||||||
status_code: string;
|
status_code: string;
|
||||||
feedback_status: string;
|
feedback_status: string;
|
||||||
@@ -346,7 +347,7 @@ const SupportNewPage: NextPageWithLayout = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const created = data as TicketCreateResponse;
|
const created = data as TicketCreateResponse;
|
||||||
setCreatedTicketId(created.ticket_id);
|
setCreatedTicketId(created.display_id ?? created.ticket_id);
|
||||||
setSubmitMessage(
|
setSubmitMessage(
|
||||||
`등록이 완료되었습니다. 첨부파일 ${attachments.length}개가 포함되었습니다.`,
|
`등록이 완료되었습니다. 첨부파일 ${attachments.length}개가 포함되었습니다.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
|||||||
const STATE_COOKIE = 'baron_sso_state';
|
const STATE_COOKIE = 'baron_sso_state';
|
||||||
const RETURN_COOKIE = 'baron_sso_return_to';
|
const RETURN_COOKIE = 'baron_sso_return_to';
|
||||||
const SESSION_COOKIE = 'jwt';
|
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;
|
const SESSION_MAX_AGE = 60 * 60;
|
||||||
|
|
||||||
interface OidcUserInfo {
|
interface OidcUserInfo {
|
||||||
@@ -14,24 +19,248 @@ interface OidcUserInfo {
|
|||||||
name?: unknown;
|
name?: unknown;
|
||||||
department?: unknown;
|
department?: unknown;
|
||||||
phone_number?: unknown;
|
phone_number?: unknown;
|
||||||
|
phone?: unknown;
|
||||||
|
phones?: unknown;
|
||||||
|
profile?: unknown;
|
||||||
|
sessionAuthenticatedAt?: unknown;
|
||||||
tenant_id?: unknown;
|
tenant_id?: unknown;
|
||||||
tenantId?: unknown;
|
tenantId?: unknown;
|
||||||
tenant_ids?: unknown;
|
tenant_ids?: unknown;
|
||||||
tenantIds?: 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) =>
|
const asString = (value: unknown) =>
|
||||||
typeof value === 'string' && value.trim() ? value.trim() : null;
|
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) =>
|
const asStringArray = (value: unknown) =>
|
||||||
Array.isArray(value)
|
Array.isArray(value)
|
||||||
? value.filter((item): item is string => Boolean(asString(item)))
|
? 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 configured = (name: string) => process.env[name]?.trim() ?? '';
|
||||||
const getIssuer = () => configured('SSO_ISSUER') || 'https://sso.hmac.kr/oidc';
|
const getIssuer = () => configured('SSO_ISSUER') || 'https://sso.hmac.kr/oidc';
|
||||||
const getEndpoint = (name: string, fallback: string) => configured(name) || fallback;
|
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) => {
|
export const getRedirectUri = (req: NextApiRequest) => {
|
||||||
const configuredUri = configured('SSO_REDIRECT_URI');
|
const configuredUri = configured('SSO_REDIRECT_URI');
|
||||||
if (configuredUri) return configuredUri;
|
if (configuredUri) return configuredUri;
|
||||||
@@ -56,6 +285,132 @@ const makeCookie = (
|
|||||||
return parts.join('; ');
|
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 isSecureRequest = (req: NextApiRequest) => {
|
||||||
const forwardedProto = req.headers['x-forwarded-proto'];
|
const forwardedProto = req.headers['x-forwarded-proto'];
|
||||||
return (
|
return (
|
||||||
@@ -87,7 +442,10 @@ export const buildLoginUrl = (req: NextApiRequest) => {
|
|||||||
url.searchParams.set('response_type', 'code');
|
url.searchParams.set('response_type', 'code');
|
||||||
url.searchParams.set('client_id', clientId);
|
url.searchParams.set('client_id', clientId);
|
||||||
url.searchParams.set('redirect_uri', getRedirectUri(req));
|
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);
|
url.searchParams.set('state', state);
|
||||||
return { state, url: url.toString() };
|
return { state, url: url.toString() };
|
||||||
};
|
};
|
||||||
@@ -127,6 +485,8 @@ const exchangeCode = async (req: NextApiRequest, code: string) => {
|
|||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
access_token?: unknown;
|
access_token?: unknown;
|
||||||
id_token?: unknown;
|
id_token?: unknown;
|
||||||
|
scope?: unknown;
|
||||||
|
token_type?: unknown;
|
||||||
error_description?: unknown;
|
error_description?: unknown;
|
||||||
};
|
};
|
||||||
if (!response.ok || typeof data.access_token !== 'string') {
|
if (!response.ok || typeof data.access_token !== 'string') {
|
||||||
@@ -137,6 +497,8 @@ const exchangeCode = async (req: NextApiRequest, code: string) => {
|
|||||||
return {
|
return {
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
idToken: asString(data.id_token) ?? undefined,
|
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;
|
const data = (await response.json()) as OidcUserInfo;
|
||||||
if (!response.ok) throw new Error('BARON-SSO 사용자 정보를 가져오지 못했습니다.');
|
if (!response.ok) throw new Error('BARON-SSO 사용자 정보를 가져오지 못했습니다.');
|
||||||
|
logSsoUserInfoShape(data);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,6 +530,8 @@ const createSupportSession = (info: OidcUserInfo) => {
|
|||||||
configured('SUPPORT_TENANT_ID') ||
|
configured('SUPPORT_TENANT_ID') ||
|
||||||
asString(info.tenant_id) ||
|
asString(info.tenant_id) ||
|
||||||
asString(info.tenantId) ||
|
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] ||
|
asStringArray(info.tenant_ids ?? info.tenantIds)[0] ||
|
||||||
'';
|
'';
|
||||||
if (!subject || !tenantId) {
|
if (!subject || !tenantId) {
|
||||||
@@ -186,7 +551,14 @@ const createSupportSession = (info: OidcUserInfo) => {
|
|||||||
email: asString(info.email),
|
email: asString(info.email),
|
||||||
name: asString(info.name),
|
name: asString(info.name),
|
||||||
department: asString(info.department),
|
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',
|
type: 'GENERAL',
|
||||||
iat: now,
|
iat: now,
|
||||||
exp: now + SESSION_MAX_AGE,
|
exp: now + SESSION_MAX_AGE,
|
||||||
@@ -202,9 +574,28 @@ export const completeLogin = async (req: NextApiRequest, res: NextApiResponse) =
|
|||||||
throw new Error('BARON-SSO state가 유효하지 않습니다. 로그인부터 다시 시도하세요.');
|
throw new Error('BARON-SSO state가 유효하지 않습니다. 로그인부터 다시 시도하세요.');
|
||||||
}
|
}
|
||||||
const tokens = await exchangeCode(req, code);
|
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 sessionToken = createSupportSession(userInfo);
|
||||||
const secure = isSecureRequest(req);
|
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', [
|
res.setHeader('Set-Cookie', [
|
||||||
makeCookie(SESSION_COOKIE, JSON.stringify({ accessToken: sessionToken, refreshToken: '' }), {
|
makeCookie(SESSION_COOKIE, JSON.stringify({ accessToken: sessionToken, refreshToken: '' }), {
|
||||||
maxAge: SESSION_MAX_AGE,
|
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(STATE_COOKIE, '', { maxAge: 0, httpOnly: true, secure }),
|
||||||
makeCookie(RETURN_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]);
|
return safeReturnTo(req.cookies[RETURN_COOKIE]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearSession = (req: NextApiRequest, res: NextApiResponse) => {
|
export const clearSession = (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const secure = isSecureRequest(req);
|
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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const SUPPORT_REQUESTER_METADATA_KEYS = [
|
|||||||
'requester_name',
|
'requester_name',
|
||||||
'requester_department',
|
'requester_department',
|
||||||
'requester_phone_number',
|
'requester_phone_number',
|
||||||
|
'requester_sso_claims',
|
||||||
'requester_contact',
|
'requester_contact',
|
||||||
'is_secret',
|
'is_secret',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import type { NextApiRequest } from 'next';
|
|||||||
import { supportExternalUrl } from './support-external';
|
import { supportExternalUrl } from './support-external';
|
||||||
|
|
||||||
type StoredJwt = { accessToken?: string };
|
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 {
|
export interface SupportPrincipal {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -31,6 +33,32 @@ export interface SupportPrincipal {
|
|||||||
roles?: string[];
|
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([
|
const SUPPORT_MANAGER_ROLES = new Set([
|
||||||
'SYSTEM_ADMIN',
|
'SYSTEM_ADMIN',
|
||||||
'SUPER_ADMIN',
|
'SUPER_ADMIN',
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export interface SupportAttachmentRecord {
|
|||||||
|
|
||||||
export interface SupportTicketRecord extends SupportFeedbackAutomationMetadata {
|
export interface SupportTicketRecord extends SupportFeedbackAutomationMetadata {
|
||||||
ticket_id: number;
|
ticket_id: number;
|
||||||
|
/** Project-scoped display number; ticket_id remains the API identifier. */
|
||||||
|
display_id?: number;
|
||||||
workspace_code: string;
|
workspace_code: string;
|
||||||
workspace_name: string;
|
workspace_name: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -89,6 +91,9 @@ export interface SupportTicketRecord extends SupportFeedbackAutomationMetadata {
|
|||||||
requester_email?: string | null;
|
requester_email?: string | null;
|
||||||
requester_name?: string | null;
|
requester_name?: string | null;
|
||||||
requester_department?: string | null;
|
requester_department?: string | null;
|
||||||
|
requester_affiliation?: string | null;
|
||||||
|
requester_position?: string | null;
|
||||||
|
requester_grade?: string | null;
|
||||||
requester_phone_number?: string | null;
|
requester_phone_number?: string | null;
|
||||||
category_code: string;
|
category_code: string;
|
||||||
ticket_type: string;
|
ticket_type: string;
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ services:
|
|||||||
SSO_AUTHORIZATION_ENDPOINT: ${SSO_AUTHORIZATION_ENDPOINT:-https://sso.hmac.kr/oidc/oauth2/auth}
|
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_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_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
|
# These are server-only values. Do not prefix them with NEXT_PUBLIC_ and
|
||||||
# do not put them in the browser build.
|
# do not put them in the browser build.
|
||||||
SSO_CLIENT_ID: ${SSO_CLIENT_ID:-}
|
SSO_CLIENT_ID: ${SSO_CLIENT_ID:-}
|
||||||
|
|||||||
Reference in New Issue
Block a user