fix: 첨부파일 오류 개선

This commit is contained in:
root
2026-09-11 17:37:37 +09:00
parent 4a79555d16
commit 2c32ae608f
16 changed files with 797 additions and 21 deletions
+407 -4
View File
@@ -4,6 +4,11 @@ import type { NextApiRequest, NextApiResponse } from 'next';
const STATE_COOKIE = 'baron_sso_state';
const RETURN_COOKIE = 'baron_sso_return_to';
const SESSION_COOKIE = 'jwt';
const DEBUG_TENANTS_COOKIE = 'baron_sso_debug_tenants';
const DEBUG_TENANTS_COOKIE_COUNT = `${DEBUG_TENANTS_COOKIE}_count`;
const SSO_TOKEN_CLAIMS_COOKIE = 'baron_sso_token_claims';
const SSO_TOKEN_CLAIMS_COOKIE_COUNT = `${SSO_TOKEN_CLAIMS_COOKIE}_count`;
const DEBUG_COOKIE_CHUNK_SIZE = 3000;
const SESSION_MAX_AGE = 60 * 60;
interface OidcUserInfo {
@@ -14,24 +19,248 @@ interface OidcUserInfo {
name?: unknown;
department?: unknown;
phone_number?: unknown;
phone?: unknown;
phones?: unknown;
profile?: unknown;
sessionAuthenticatedAt?: unknown;
tenant_id?: unknown;
tenantId?: unknown;
tenant_ids?: unknown;
tenantIds?: unknown;
tenants?: unknown;
tenantSlug?: unknown;
tenant?: unknown;
joinedTenants?: unknown;
affiliationType?: unknown;
grade?: unknown;
position?: unknown;
jobTitle?: unknown;
}
interface SsoOrgMember {
id?: unknown;
email?: unknown;
name?: unknown;
phone?: unknown;
department?: unknown;
grade?: unknown;
position?: unknown;
jobTitle?: unknown;
}
type SsoValueShape =
| string
| { type: 'array'; length: number; items: SsoValueShape[] }
| { type: 'object'; keys: Record<string, SsoValueShape> };
const getSsoValueShape = (value: unknown, depth = 0): SsoValueShape => {
if (depth >= 4) return typeof value;
if (Array.isArray(value)) {
const itemShapes = value
.slice(0, 5)
.map((item) => getSsoValueShape(item, depth + 1));
const uniqueShapes = itemShapes.filter(
(shape, index, shapes) =>
shapes.findIndex((candidate) => JSON.stringify(candidate) === JSON.stringify(shape)) ===
index,
);
return {
type: 'array',
length: value.length,
items: uniqueShapes,
};
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
return {
type: 'object',
keys: Object.fromEntries(
Object.entries(record).map(([key, nestedValue]) => [
key,
getSsoValueShape(nestedValue, depth + 1),
]),
),
};
}
return typeof value;
};
const logSsoUserInfoShape = (data: OidcUserInfo) => {
if (configured('SSO_DEBUG_USERINFO') !== 'true') return;
console.info('[SSO userinfo] response shape:', JSON.stringify(getSsoValueShape(data)));
};
const asString = (value: unknown) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
// OAuth access tokens are not always JWTs. This is decode-only debug support;
// it never logs or stores the original token value.
const decodeJwtPayload = (token: string | undefined): Record<string, unknown> | null => {
if (!token) return null;
const parts = token.split('.');
if (parts.length < 2) return null;
try {
const parsed = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
};
const asStringArray = (value: unknown) =>
Array.isArray(value)
? value.filter((item): item is string => Boolean(asString(item)))
: [];
const getPhoneNumber = (info: OidcUserInfo) => {
const profile =
info.profile && typeof info.profile === 'object' ?
(info.profile as Record<string, unknown>)
: null;
const sources = [
info.phone_number,
info.phone,
info.phones,
profile?.phone_number,
profile?.phone,
profile?.phones,
];
const findPhone = (value: unknown): string | null => {
const direct = asString(value);
if (direct) return direct;
if (Array.isArray(value)) {
for (const item of value) {
const phone = findPhone(item);
if (phone) return phone;
}
return null;
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
for (const key of [
'phone_number',
'phoneNumber',
'phone',
'number',
'value',
'mobile',
'telephone',
'tel',
]) {
const phone = findPhone(record[key]);
if (phone) return phone;
}
}
return null;
};
for (const source of sources) {
const phone = findPhone(source);
if (phone) return phone;
}
return null;
};
const configured = (name: string) => process.env[name]?.trim() ?? '';
const getIssuer = () => configured('SSO_ISSUER') || 'https://sso.hmac.kr/oidc';
const getEndpoint = (name: string, fallback: string) => configured(name) || fallback;
const asRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === 'object' && !Array.isArray(value) ?
(value as Record<string, unknown>)
: null;
const getSsoOrgMember = async (
accessToken: string,
info: OidcUserInfo,
): Promise<SsoOrgMember | null> => {
const currentTenant = asRecord(info.tenant);
const joinedTenants = Array.isArray(info.joinedTenants) ? info.joinedTenants : [];
const firstJoinedTenant = asRecord(joinedTenants[0]);
const tenantSlug =
asString(info.tenantSlug) ??
asString(currentTenant?.slug) ??
asString(firstJoinedTenant?.slug);
const tenantId =
asString(info.tenant_id) ??
asString(info.tenantId) ??
asString(currentTenant?.id) ??
asString(firstJoinedTenant?.id);
if (!tenantSlug && !tenantId) return null;
const endpoint = new URL(
getEndpoint(
'SSO_ORG_CONTEXT_ENDPOINT',
'https://sso.hmac.kr/api/v1/integrations/org-context',
),
);
if (tenantSlug) endpoint.searchParams.set('tenantSlug', tenantSlug);
else if (tenantId) endpoint.searchParams.set('tenantId', tenantId);
endpoint.searchParams.set('exposure', 'members');
const response = await fetch(endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) return null;
const data = (await response.json()) as {
tree?: { members?: unknown };
tenants?: unknown;
};
const members: Record<string, unknown>[] = [];
const treeMembers = Array.isArray(data.tree?.members) ? data.tree.members : [];
const tenantMembers = Array.isArray(data.tenants) ?
data.tenants.flatMap((tenant) => {
const record = asRecord(tenant);
return Array.isArray(record?.members) ? record.members : [];
})
: [];
for (const candidate of [...treeMembers, ...tenantMembers]) {
const member = asRecord(candidate);
if (member) members.push(member);
}
const subject = asString(info.id) ?? asString(info.sub) ?? asString(info.user_id);
const email = asString(info.email);
return (
members.find((member) => {
const memberId = asString(member.id);
const memberEmail = asString(member.email);
return (subject && memberId === subject) || (email && memberEmail === email);
}) ?? null
);
};
const enrichSsoUserInfo = async (accessToken: string, info: OidcUserInfo) => {
const member = await getSsoOrgMember(accessToken, info).catch(() => null);
if (!member) return info;
return {
...info,
email: asString(info.email) ?? asString(member.email),
name: asString(info.name) ?? asString(member.name),
phone: asString(info.phone) ?? asString(member.phone),
department: asString(info.department) ?? asString(member.department),
grade: asString(info.grade) ?? asString(member.grade),
position: asString(info.position) ?? asString(member.position),
jobTitle: asString(info.jobTitle) ?? asString(member.jobTitle),
};
};
export const getRedirectUri = (req: NextApiRequest) => {
const configuredUri = configured('SSO_REDIRECT_URI');
if (configuredUri) return configuredUri;
@@ -56,6 +285,132 @@ const makeCookie = (
return parts.join('; ');
};
const makeJsonCookies = (
name: string,
value: unknown,
secure: boolean,
httpOnly: boolean,
) => {
const serialized = JSON.stringify(value);
const encoded = encodeURIComponent(serialized);
if (encoded.length <= DEBUG_COOKIE_CHUNK_SIZE) {
return [
makeCookie(`${name}_count`, '', { maxAge: 0, httpOnly, secure }),
makeCookie(name, serialized, { maxAge: 60 * 60, httpOnly, secure }),
];
}
const chunks: string[] = [];
let currentChunk = '';
for (const character of serialized) {
const nextChunk = `${currentChunk}${character}`;
if (
currentChunk &&
encodeURIComponent(nextChunk).length > DEBUG_COOKIE_CHUNK_SIZE
) {
chunks.push(currentChunk);
currentChunk = character;
} else {
currentChunk = nextChunk;
}
}
if (currentChunk) chunks.push(currentChunk);
const cookies = [
makeCookie(name, '', { maxAge: 0, httpOnly, secure }),
makeCookie(`${name}_count`, String(chunks.length), {
maxAge: 60 * 60,
httpOnly,
secure,
}),
];
for (const [index, chunk] of chunks.entries()) {
cookies.push(
makeCookie(
`${name}_${index}`,
chunk,
{ maxAge: 60 * 60, httpOnly, secure },
),
);
}
return cookies;
};
const makeDebugCookies = (value: unknown, secure: boolean) =>
makeJsonCookies(DEBUG_TENANTS_COOKIE, value, secure, false);
const getSessionAccessToken = (req: NextApiRequest) => {
const authorization = req.headers.authorization;
if (authorization?.startsWith('Bearer ')) {
return asString(authorization.slice('Bearer '.length));
}
const rawCookie = req.cookies[SESSION_COOKIE];
if (!rawCookie) return null;
try {
const parsed = JSON.parse(decodeURIComponent(rawCookie)) as { accessToken?: unknown };
return asString(parsed.accessToken);
} catch {
return null;
}
};
export const getSsoDebugInfo = async (req: NextApiRequest) => {
if (configured('SSO_DEBUG_USERINFO') !== 'true') return null;
const rawDebugCookie = req.cookies[DEBUG_TENANTS_COOKIE];
if (rawDebugCookie) {
try {
return JSON.parse(decodeURIComponent(rawDebugCookie)) as unknown;
} catch {
// Fall through to the live lookup for malformed or expired debug data.
}
}
const accessToken = getSessionAccessToken(req);
if (!accessToken) return null;
try {
const userInfo = await enrichSsoUserInfo(accessToken, await getUserInfo(accessToken));
return {
source: 'userinfo + org-context',
rawUserInfoUser: {
id: userInfo.id ?? userInfo.sub ?? userInfo.user_id ?? null,
email: userInfo.email ?? null,
name: userInfo.name ?? null,
phone: userInfo.phone ?? userInfo.phone_number ?? null,
phones: userInfo.phones ?? null,
sessionAuthenticatedAt: userInfo.sessionAuthenticatedAt ?? null,
department: userInfo.department ?? null,
affiliationType: userInfo.affiliationType ?? null,
tenantSlug: userInfo.tenantSlug ?? null,
},
rawUserInfoTenants: {
tenant: userInfo.tenant ?? null,
joinedTenants: Array.isArray(userInfo.joinedTenants) ? userInfo.joinedTenants : [],
},
user: {
id: asString(userInfo.id) ?? asString(userInfo.sub) ?? asString(userInfo.user_id),
name: asString(userInfo.name),
},
tenant: {
id: asString(asRecord(userInfo.tenant)?.id),
name: asString(asRecord(userInfo.tenant)?.name),
slug: asString(userInfo.tenantSlug) ?? asString(asRecord(userInfo.tenant)?.slug),
},
member: {
department: asString(userInfo.department),
grade: asString(userInfo.grade),
position: asString(userInfo.position),
jobTitle: asString(userInfo.jobTitle),
},
};
} catch {
return null;
}
};
const isSecureRequest = (req: NextApiRequest) => {
const forwardedProto = req.headers['x-forwarded-proto'];
return (
@@ -87,7 +442,10 @@ export const buildLoginUrl = (req: NextApiRequest) => {
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', getRedirectUri(req));
url.searchParams.set('scope', configured('SSO_SCOPE') || 'openid profile email');
url.searchParams.set(
'scope',
configured('SSO_SCOPE') || 'openid profile email tenants',
);
url.searchParams.set('state', state);
return { state, url: url.toString() };
};
@@ -127,6 +485,8 @@ const exchangeCode = async (req: NextApiRequest, code: string) => {
const data = (await response.json()) as {
access_token?: unknown;
id_token?: unknown;
scope?: unknown;
token_type?: unknown;
error_description?: unknown;
};
if (!response.ok || typeof data.access_token !== 'string') {
@@ -137,6 +497,8 @@ const exchangeCode = async (req: NextApiRequest, code: string) => {
return {
accessToken: data.access_token,
idToken: asString(data.id_token) ?? undefined,
scope: asString(data.scope) ?? undefined,
tokenType: asString(data.token_type) ?? undefined,
};
};
@@ -147,6 +509,7 @@ const getUserInfo = async (accessToken: string): Promise<OidcUserInfo> => {
);
const data = (await response.json()) as OidcUserInfo;
if (!response.ok) throw new Error('BARON-SSO 사용자 정보를 가져오지 못했습니다.');
logSsoUserInfoShape(data);
return data;
};
@@ -167,6 +530,8 @@ const createSupportSession = (info: OidcUserInfo) => {
configured('SUPPORT_TENANT_ID') ||
asString(info.tenant_id) ||
asString(info.tenantId) ||
asString(asRecord(info.tenant)?.id) ||
asString(asRecord(Array.isArray(info.joinedTenants) ? info.joinedTenants[0] : null)?.id) ||
asStringArray(info.tenant_ids ?? info.tenantIds)[0] ||
'';
if (!subject || !tenantId) {
@@ -186,7 +551,14 @@ const createSupportSession = (info: OidcUserInfo) => {
email: asString(info.email),
name: asString(info.name),
department: asString(info.department),
phone_number: asString(info.phone_number),
phone_number: getPhoneNumber(info),
affiliation_type: asString(info.affiliationType),
tenant_slug:
asString(info.tenantSlug) ?? asString(asRecord(info.tenant)?.slug),
tenant_name: asString(asRecord(info.tenant)?.name),
grade: asString(info.grade),
position: asString(info.position),
job_title: asString(info.jobTitle),
type: 'GENERAL',
iat: now,
exp: now + SESSION_MAX_AGE,
@@ -202,9 +574,28 @@ export const completeLogin = async (req: NextApiRequest, res: NextApiResponse) =
throw new Error('BARON-SSO state가 유효하지 않습니다. 로그인부터 다시 시도하세요.');
}
const tokens = await exchangeCode(req, code);
const userInfo = await getUserInfo(tokens.accessToken);
const idTokenClaims = decodeJwtPayload(tokens.idToken);
const accessTokenClaims = decodeJwtPayload(tokens.accessToken);
const ssoTokenClaims: Record<string, unknown> = {
requested_scopes: configured('SSO_SCOPE') || 'openid profile email tenants',
token_response_scope: tokens.scope ?? null,
token_type: tokens.tokenType ?? null,
id_token: idTokenClaims,
access_token: accessTokenClaims,
};
const rawUserInfo = await getUserInfo(tokens.accessToken);
const userInfo = await enrichSsoUserInfo(tokens.accessToken, rawUserInfo);
ssoTokenClaims.userinfo = userInfo as Record<string, unknown>;
const sessionToken = createSupportSession(userInfo);
const secure = isSecureRequest(req);
const debugOrgContext = {
source: 'oauth token claims (decode-only)',
requestedScopes: configured('SSO_SCOPE') || 'openid profile email tenants',
tokenResponseScope: tokens.scope ?? null,
tokenType: tokens.tokenType ?? null,
idTokenClaims,
accessTokenClaims,
};
res.setHeader('Set-Cookie', [
makeCookie(SESSION_COOKIE, JSON.stringify({ accessToken: sessionToken, refreshToken: '' }), {
maxAge: SESSION_MAX_AGE,
@@ -213,11 +604,23 @@ export const completeLogin = async (req: NextApiRequest, res: NextApiResponse) =
}),
makeCookie(STATE_COOKIE, '', { maxAge: 0, httpOnly: true, secure }),
makeCookie(RETURN_COOKIE, '', { maxAge: 0, httpOnly: true, secure }),
...makeJsonCookies(SSO_TOKEN_CLAIMS_COOKIE, ssoTokenClaims, secure, true),
...(configured('SSO_DEBUG_USERINFO') === 'true' ?
makeDebugCookies(debugOrgContext, secure)
: []),
]);
return safeReturnTo(req.cookies[RETURN_COOKIE]);
};
export const clearSession = (req: NextApiRequest, res: NextApiResponse) => {
const secure = isSecureRequest(req);
res.setHeader('Set-Cookie', makeCookie(SESSION_COOKIE, '', { maxAge: 0, httpOnly: true, secure }));
res.setHeader('Set-Cookie', [
makeCookie(SESSION_COOKIE, '', { maxAge: 0, httpOnly: true, secure }),
makeCookie(SSO_TOKEN_CLAIMS_COOKIE, '', { maxAge: 0, httpOnly: true, secure }),
makeCookie(SSO_TOKEN_CLAIMS_COOKIE_COUNT, '', {
maxAge: 0,
httpOnly: true,
secure,
}),
]);
};