Author SHA1 Message Date
root a07698b17f debug: SSO 전체 테넌트 ID 확인
Deploy feedback demo / deploy (push) Successful in 57s
2026-09-14 13:41:49 +09:00
root 407511ea13 fix: support API 연결 오류 진단 개선
Deploy feedback demo / deploy (push) Successful in 47s
2026-09-14 13:27:54 +09:00
root 41cb084479 fix: 전체 테넌트 ID 전송 수정
Deploy feedback demo / deploy (push) Successful in 44s
2026-09-14 13:17:57 +09:00
root be241d4eea Merge branch 'main' of https://gitea.hmac.kr/b24014/egbim_qa_platform into refactor/feedback-writer-slim
Deploy feedback demo / deploy (push) Successful in 46s
2026-09-11 17:39:18 +09:00
root 2c32ae608f fix: 첨부파일 오류 개선 2026-09-11 17:37:37 +09:00
b24014 43a0bb23a0 Merge pull request 'feat: 확인 완료 버튼 추가' (#1) from refactor/feedback-writer-slim into main
Deploy feedback demo / deploy (push) Successful in 45s
Reviewed-on: #1
2026-09-04 16:51:51 +09:00
24 changed files with 951 additions and 41 deletions
+5 -1
View File
@@ -65,6 +65,7 @@ jobs:
SSO_TOKEN_ENDPOINT: ${{ vars.SSO_TOKEN_ENDPOINT }}
SSO_USERINFO_ENDPOINT: ${{ vars.SSO_USERINFO_ENDPOINT }}
SSO_SCOPE: ${{ vars.SSO_SCOPE }}
SSO_DEBUG_USERINFO: ${{ vars.SSO_DEBUG_USERINFO }}
SSO_CLIENT_ID_VAR: ${{ vars.SSO_CLIENT_ID }}
SSO_CLIENT_ID_SECRET: ${{ secrets.SSO_CLIENT_ID }}
SUPPORT_TENANT_ID: ${{ vars.SUPPORT_TENANT_ID }}
@@ -87,7 +88,8 @@ jobs:
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_scope="${SSO_SCOPE:-openid profile email tenants}"
sso_debug_userinfo="${SSO_DEBUG_USERINFO:-false}"
sso_client_id="${SSO_CLIENT_ID_SECRET:-${SSO_CLIENT_ID_VAR:-}}"
sso_client_secret="$SSO_CLIENT_SECRET"
jwt_secret="$JWT_SECRET"
@@ -148,6 +150,7 @@ jobs:
sso_token_endpoint_b64="$(base64_value "$sso_token_endpoint")"
sso_userinfo_endpoint_b64="$(base64_value "$sso_userinfo_endpoint")"
sso_scope_b64="$(base64_value "$sso_scope")"
sso_debug_userinfo_b64="$(base64_value "$sso_debug_userinfo")"
sso_client_id_b64="$(base64_value "$sso_client_id")"
sso_client_secret_b64="$(base64_value "$sso_client_secret")"
jwt_secret_b64="$(base64_value "$jwt_secret")"
@@ -172,6 +175,7 @@ jobs:
export SSO_TOKEN_ENDPOINT="\$(printf '%s' '$sso_token_endpoint_b64' | base64 -d)"
export SSO_USERINFO_ENDPOINT="\$(printf '%s' '$sso_userinfo_endpoint_b64' | base64 -d)"
export SSO_SCOPE="\$(printf '%s' '$sso_scope_b64' | base64 -d)"
export SSO_DEBUG_USERINFO="\$(printf '%s' '$sso_debug_userinfo_b64' | base64 -d)"
export SSO_CLIENT_ID="\$(printf '%s' '$sso_client_id_b64' | base64 -d)"
export SSO_CLIENT_SECRET="\$(printf '%s' '$sso_client_secret_b64' | base64 -d)"
export JWT_SECRET="\$(printf '%s' '$jwt_secret_b64' | base64 -d)"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,43 @@
"""store all SSO tenant memberships on support tickets"""
from __future__ import annotations
from alembic import op
from sqlalchemy import inspect
import sqlalchemy as sa
revision = "0017_ticket_requester_tenant_ids"
down_revision = "0016_feedback_status_stages"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
columns = {
column["name"]
for column in inspect(bind).get_columns("support_tickets")
}
if "requester_tenant_ids" not in columns:
op.add_column(
"support_tickets",
sa.Column("requester_tenant_ids", sa.JSON(), nullable=True),
)
op.execute(
"""
UPDATE support_tickets
SET requester_tenant_ids = JSON_ARRAY(requester_tenant_id)
WHERE requester_tenant_ids IS NULL
"""
)
def downgrade() -> None:
bind = op.get_bind()
columns = {
column["name"]
for column in inspect(bind).get_columns("support_tickets")
}
if "requester_tenant_ids" in columns:
op.drop_column("support_tickets", "requester_tenant_ids")
@@ -209,6 +209,7 @@ def get_my_access(
return {
"user_id": principal.user_id,
"tenant_id": principal.tenant_id,
"tenant_ids": principal.tenant_ids,
"email": principal.email,
"name": principal.name,
"department": principal.department,
+9 -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)
@@ -271,6 +276,7 @@ async def create_ticket(
# because empty values fail min_length validation first.
requester_id=principal.user_id,
requester_tenant_id=principal.tenant_id,
requester_tenant_ids=principal.tenant_ids,
requester_contact=str(form.get("requester_contact") or ""),
title=str(form.get("title") or ""),
description=str(form.get("description") or ""),
@@ -283,6 +289,7 @@ async def create_ticket(
request_payload = request_payload.model_copy(update={
"requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id,
"requester_tenant_ids": principal.tenant_ids,
"requester_email": principal.email,
"requester_name": principal.name,
"requester_department": principal.department,
@@ -295,6 +302,7 @@ async def create_ticket(
"workspace_code": workspace_code,
"requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id,
"requester_tenant_ids": principal.tenant_ids,
"requester_email": principal.email,
"requester_name": principal.name,
"requester_department": principal.department,
+1
View File
@@ -144,6 +144,7 @@ class SupportTicket(Base):
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
requester_id: Mapped[str] = mapped_column(String(100))
requester_tenant_id: Mapped[str] = mapped_column(String(100))
requester_tenant_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
requester_contact: Mapped[str | None] = mapped_column(String(100), nullable=True)
requester_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
requester_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
+2
View File
@@ -7,6 +7,7 @@ class TicketCreateRequest(BaseModel):
workspace_code: str = Field(default="", max_length=50)
requester_id: str = Field(..., min_length=1, max_length=100)
requester_tenant_id: str = Field(..., min_length=1, max_length=100)
requester_tenant_ids: list[str] = Field(default_factory=list)
requester_contact: str | None = Field(default=None, max_length=100)
requester_email: str | None = Field(default=None, max_length=320)
requester_name: str | None = Field(default=None, max_length=100)
@@ -187,6 +188,7 @@ class SupportTicketRecord(BaseModel):
description: str
requester_id: str
requester_tenant_id: str
requester_tenant_ids: list[str] = Field(default_factory=list)
requester_contact: str
requester_email: str | None
requester_name: str | None
@@ -169,6 +169,7 @@ class TicketService:
workspace_id=workspace.id,
requester_id=payload.requester_id,
requester_tenant_id=payload.requester_tenant_id,
requester_tenant_ids=payload.requester_tenant_ids or [payload.requester_tenant_id],
requester_contact=payload.requester_contact or "",
requester_email=payload.requester_email,
requester_name=payload.requester_name,
@@ -1301,6 +1302,7 @@ class TicketService:
description=ticket.description,
requester_id=ticket.requester_id,
requester_tenant_id=ticket.requester_tenant_id,
requester_tenant_ids=ticket.requester_tenant_ids or [ticket.requester_tenant_id],
requester_contact=ticket.requester_contact or "",
requester_email=requester_email,
requester_name=requester_name,
+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=
+37
View File
@@ -61,6 +61,43 @@ 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) 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);
}
+24 -12
View File
@@ -17,22 +17,34 @@ import { createNextApiHandler } from '@/server/api-handler';
import { getSupportAuthHeaders } from '@/server/support-auth';
import { supportExternalUrl } from '@/server/support-external';
const getErrorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
const handler = createNextApiHandler({
GET: async (req, res) => {
const query =
req.query.candidates ?
'?candidates=1'
: req.query.management ?
'?management=1'
: '';
const target = supportExternalUrl('/access') + query;
try {
const query =
req.query.candidates ?
'?candidates=1'
: req.query.management ?
'?management=1'
: '';
const response = await fetch(
supportExternalUrl('/access') + query,
{ headers: getSupportAuthHeaders(req) },
);
const data = (await response.json()) as unknown;
const response = await fetch(target, { headers: getSupportAuthHeaders(req) });
const raw = await response.text();
let data: unknown = {};
try {
data = raw.trim() ? JSON.parse(raw) : {};
} catch {
data = { message: raw.trim().slice(0, 1000) };
}
return res.status(response.status).json(data);
} catch {
} catch (error) {
console.error('[support access] upstream request failed', {
target,
error: getErrorMessage(error),
});
return res.status(503).json({
message: '권한 정보를 확인할 수 없습니다.',
});
@@ -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,114 @@ 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',
);
const tenantIds = new Set<string>();
const addTenantId = (value: unknown) => {
if (typeof value === 'string' && value.trim()) tenantIds.add(value.trim());
};
for (const source of tokenSources) {
const directTenantIds = source.tenant_ids ?? source.tenantIds;
if (Array.isArray(directTenantIds)) {
directTenantIds.forEach(addTenantId);
}
addTenantId(source.tenant_id);
addTenantId(source.tenantId);
addTenantId(asRecord(source.tenant)?.id);
if (Array.isArray(source.joinedTenants)) {
source.joinedTenants.forEach((tenant) => addTenantId(asRecord(tenant)?.id));
}
if (Array.isArray(source.tenants)) {
source.tenants.forEach((tenant) => addTenantId(asRecord(tenant)?.id));
} else if (source.tenants && typeof source.tenants === 'object') {
Object.values(source.tenants as Record<string, unknown>).forEach((tenant) =>
addTenantId(asRecord(tenant)?.id),
);
}
}
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',
),
requester_tenant_ids: JSON.stringify([...tenantIds]),
};
};
const MAX_ATTACHMENT_COUNT = 10;
const parseMultipartBody = (req: NextApiRequest) =>
@@ -133,13 +237,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 +260,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 +280,7 @@ const handler = createNextApiHandler({
'Content-Type': 'application/json',
...getSupportAuthHeaders(req),
},
body: Buffer.concat(chunks),
body: requestBody,
signal: AbortSignal.timeout(60000),
},
);
@@ -169,6 +288,10 @@ const handler = createNextApiHandler({
return res.status(response.status).json(await readJsonOrMessage(response));
} catch (error) {
console.error('[support submit] upstream request failed', {
workspaceCode,
error: error instanceof Error ? error.message : String(error),
});
return res.status(isUploadSizeError(error) ? 413 : 502).json({
message:
isUploadSizeError(error) ?
@@ -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}개가 포함되었습니다.`,
);
+448 -7
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,267 @@ 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;
tenant_ids?: string[];
}
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 tenantIds = new Set<string>();
const rawTenants = Array.isArray(data.tenants) ?
data.tenants
: data.tenants && typeof data.tenants === 'object' ?
Object.values(data.tenants as Record<string, unknown>)
: [];
const tenantRecords = rawTenants
.map((tenant) => asRecord(tenant))
.filter((tenant): tenant is Record<string, unknown> => tenant !== null);
for (const tenant of tenantRecords) {
const id = asString(tenant.id) ?? asString(tenant.tenant_id);
if (id) tenantIds.add(id);
}
if (tenantId) tenantIds.add(tenantId);
const members: Record<string, unknown>[] = [];
const treeMembers = Array.isArray(data.tree?.members) ? data.tree.members : [];
const tenantMembers = tenantRecords.flatMap((tenant) =>
Array.isArray(tenant.members) ? tenant.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);
const matchedMember = members.find((member) => {
const memberId = asString(member.id);
const memberEmail = asString(member.email);
return (subject && memberId === subject) || (email && memberEmail === email);
});
return matchedMember || tenantIds.size > 0 ?
{ ...(matchedMember ?? {}), tenant_ids: [...tenantIds] }
: 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),
tenant_ids: [
...new Set([
...asStringArray(info.tenant_ids ?? info.tenantIds),
...(member.tenant_ids ?? []),
]),
],
};
};
export const getRedirectUri = (req: NextApiRequest) => {
const configuredUri = configured('SSO_REDIRECT_URI');
if (configuredUri) return configuredUri;
@@ -56,6 +304,133 @@ 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 : [],
},
tenantIds: asStringArray(userInfo.tenant_ids ?? userInfo.tenantIds),
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 +462,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 +505,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 +517,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 +529,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 +550,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) {
@@ -174,19 +559,44 @@ const createSupportSession = (info: OidcUserInfo) => {
'SSO 사용자 정보에 sub 또는 tenant_id가 없습니다. SUPPORT_TENANT_ID를 확인하세요.',
);
}
const tenantIds = asStringArray(info.tenant_ids ?? info.tenantIds);
if (!tenantIds.includes(tenantId)) tenantIds.unshift(tenantId);
const tenantIds = new Set(asStringArray(info.tenant_ids ?? info.tenantIds));
const addTenantId = (value: unknown) => {
const id = asString(value);
if (id) tenantIds.add(id);
};
addTenantId(info.tenant_id);
addTenantId(info.tenantId);
addTenantId(asRecord(info.tenant)?.id);
for (const tenant of Array.isArray(info.joinedTenants) ? info.joinedTenants : []) {
addTenantId(asRecord(tenant)?.id);
}
if (Array.isArray(info.tenants)) {
for (const tenant of info.tenants) addTenantId(asRecord(tenant)?.id);
} else if (info.tenants && typeof info.tenants === 'object') {
for (const tenant of Object.values(info.tenants as Record<string, unknown>)) {
addTenantId(asRecord(tenant)?.id);
}
}
tenantIds.add(tenantId);
const now = Math.floor(Date.now() / 1000);
return signHs256(
{
sub: subject,
sso_sub: subject,
tenant_id: tenantId,
tenant_ids: tenantIds,
tenant_ids: [...tenantIds],
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 +612,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 +642,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;
+29
View File
@@ -18,10 +18,13 @@ 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;
tenant_id: string;
tenant_ids?: string[];
email?: string | null;
name?: string | null;
department?: string | null;
@@ -31,6 +34,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 -3
View File
@@ -6,9 +6,11 @@
* URL such as `http://127.0.0.1:8010`; both forms are supported here.
*/
const configuredBaseUrl =
process.env.SUPPORT_CONSOLE_API_BASE_URL ??
process.env.SUPPORT_API_BASE_URL ??
'https://feedback.hmac.kr/api/support';
(
process.env.SUPPORT_CONSOLE_API_BASE_URL ??
process.env.SUPPORT_API_BASE_URL ??
'https://feedback.hmac.kr/api/support'
).trim() || 'https://feedback.hmac.kr/api/support';
export const supportExternalUrl = (path: string) => {
const baseUrl = configuredBaseUrl.replace(/\/$/, '');
+6
View File
@@ -79,16 +79,22 @@ 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;
description: string;
requester_id: string;
requester_tenant_id: string;
requester_tenant_ids?: string[];
requester_contact: string;
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:-}