import { createHmac, randomBytes } from 'node:crypto'; 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 { sub?: unknown; user_id?: unknown; id?: unknown; email?: unknown; 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 }; const getSsoValueShape = (value: unknown, depth = 0): SsoValueShape => { if (depth >= 4) return typeof value; if (Array.isArray(value)) { const itemShapes = value .slice(0, 5) .map((item) => getSsoValueShape(item, depth + 1)); const uniqueShapes = itemShapes.filter( (shape, index, shapes) => shapes.findIndex((candidate) => JSON.stringify(candidate) === JSON.stringify(shape)) === index, ); return { type: 'array', length: value.length, items: uniqueShapes, }; } if (value && typeof value === 'object') { const record = value as Record; return { type: 'object', keys: Object.fromEntries( Object.entries(record).map(([key, nestedValue]) => [ key, getSsoValueShape(nestedValue, depth + 1), ]), ), }; } return typeof value; }; const logSsoUserInfoShape = (data: OidcUserInfo) => { if (configured('SSO_DEBUG_USERINFO') !== 'true') return; console.info('[SSO userinfo] response shape:', JSON.stringify(getSsoValueShape(data))); }; const asString = (value: unknown) => typeof value === 'string' && value.trim() ? value.trim() : null; // OAuth access tokens are not always JWTs. This is decode-only debug support; // it never logs or stores the original token value. const decodeJwtPayload = (token: string | undefined): Record | null => { if (!token) return null; const parts = token.split('.'); if (parts.length < 2) return null; try { const parsed = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); return parsed && typeof parsed === 'object' ? (parsed as Record) : null; } catch { return null; } }; const asStringArray = (value: unknown) => Array.isArray(value) ? value.filter((item): item is string => Boolean(asString(item))) : []; const getPhoneNumber = (info: OidcUserInfo) => { const profile = info.profile && typeof info.profile === 'object' ? (info.profile as Record) : null; const sources = [ info.phone_number, info.phone, info.phones, profile?.phone_number, profile?.phone, profile?.phones, ]; const findPhone = (value: unknown): string | null => { const direct = asString(value); if (direct) return direct; if (Array.isArray(value)) { for (const item of value) { const phone = findPhone(item); if (phone) return phone; } return null; } if (value && typeof value === 'object') { const record = value as Record; for (const key of [ 'phone_number', 'phoneNumber', 'phone', 'number', 'value', 'mobile', 'telephone', 'tel', ]) { const phone = findPhone(record[key]); if (phone) return phone; } } return null; }; for (const source of sources) { const phone = findPhone(source); if (phone) return phone; } return null; }; const configured = (name: string) => process.env[name]?.trim() ?? ''; const getIssuer = () => configured('SSO_ISSUER') || 'https://sso.hmac.kr/oidc'; const getEndpoint = (name: string, fallback: string) => configured(name) || fallback; const asRecord = (value: unknown): Record | null => value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; const getSsoOrgMember = async ( accessToken: string, info: OidcUserInfo, ): Promise => { const currentTenant = asRecord(info.tenant); const joinedTenants = Array.isArray(info.joinedTenants) ? info.joinedTenants : []; const firstJoinedTenant = asRecord(joinedTenants[0]); const tenantSlug = asString(info.tenantSlug) ?? asString(currentTenant?.slug) ?? asString(firstJoinedTenant?.slug); const tenantId = asString(info.tenant_id) ?? asString(info.tenantId) ?? asString(currentTenant?.id) ?? asString(firstJoinedTenant?.id); if (!tenantSlug && !tenantId) return null; const endpoint = new URL( getEndpoint( 'SSO_ORG_CONTEXT_ENDPOINT', 'https://sso.hmac.kr/api/v1/integrations/org-context', ), ); if (tenantSlug) endpoint.searchParams.set('tenantSlug', tenantSlug); else if (tenantId) endpoint.searchParams.set('tenantId', tenantId); endpoint.searchParams.set('exposure', 'members'); const response = await fetch(endpoint, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!response.ok) return null; const data = (await response.json()) as { tree?: { members?: unknown }; tenants?: unknown; }; const tenantIds = new Set(); const rawTenants = Array.isArray(data.tenants) ? data.tenants : data.tenants && typeof data.tenants === 'object' ? Object.values(data.tenants as Record) : []; const tenantRecords = rawTenants .map((tenant) => asRecord(tenant)) .filter((tenant): tenant is Record => 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[] = []; 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; const forwardedProto = req.headers['x-forwarded-proto']; const protocol = typeof forwardedProto === 'string' ? forwardedProto.split(',')[0] : 'http'; const host = req.headers.host; if (!host) throw new Error('요청 호스트를 확인할 수 없습니다.'); return `${protocol}://${host}/api/auth/baron-sso/callback`; }; const makeCookie = ( name: string, value: string, options: { maxAge?: number; httpOnly?: boolean; secure?: boolean } = {}, ) => { const parts = [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'SameSite=Lax']; if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`); if (options.httpOnly) parts.push('HttpOnly'); if (options.secure) parts.push('Secure'); 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 ( (typeof forwardedProto === 'string' && forwardedProto.split(',')[0] === 'https') || (process.env.NODE_ENV === 'production' && Boolean(process.env.COOKIE_SECURE)) ); }; const safeReturnTo = (value: unknown) => typeof value === 'string' && /^\/support\/[^/]+\/(?:list|new)(?:\?.*)?$/.test(value) ? value : '/support/EGBIM_DEMO/list'; const requireClientConfig = () => { const clientId = configured('SSO_CLIENT_ID'); const clientSecret = configured('SSO_CLIENT_SECRET'); if (!clientId || !clientSecret) { throw new Error('SSO_CLIENT_ID와 SSO_CLIENT_SECRET이 설정되지 않았습니다.'); } return { clientId, clientSecret }; }; export const buildLoginUrl = (req: NextApiRequest) => { const { clientId } = requireClientConfig(); const state = randomBytes(32).toString('base64url'); const url = new URL( getEndpoint('SSO_AUTHORIZATION_ENDPOINT', `${getIssuer()}/oauth2/auth`), ); 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 tenants', ); url.searchParams.set('state', state); return { state, url: url.toString() }; }; export const setLoginCookies = ( req: NextApiRequest, res: NextApiResponse, state: string, returnTo: unknown, ) => { const secure = isSecureRequest(req); res.setHeader('Set-Cookie', [ makeCookie(STATE_COOKIE, state, { maxAge: 600, httpOnly: true, secure }), makeCookie(RETURN_COOKIE, safeReturnTo(returnTo), { maxAge: 600, httpOnly: true, secure }), ]); }; const exchangeCode = async (req: NextApiRequest, code: string) => { const { clientId, clientSecret } = requireClientConfig(); const response = await fetch( getEndpoint('SSO_TOKEN_ENDPOINT', `${getIssuer()}/oauth2/token`), { method: 'POST', headers: { // BARON-SSO requires confidential clients to authenticate at the // token endpoint using HTTP Basic (RFC 6749 client_secret_basic). Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: getRedirectUri(req), }), }, ); 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') { throw new Error( asString(data.error_description) ?? 'BARON-SSO 토큰 교환에 실패했습니다.', ); } return { accessToken: data.access_token, idToken: asString(data.id_token) ?? undefined, scope: asString(data.scope) ?? undefined, tokenType: asString(data.token_type) ?? undefined, }; }; const getUserInfo = async (accessToken: string): Promise => { const response = await fetch( getEndpoint('SSO_USERINFO_ENDPOINT', `${getIssuer()}/userinfo`), { headers: { Authorization: `Bearer ${accessToken}` } }, ); const data = (await response.json()) as OidcUserInfo; if (!response.ok) throw new Error('BARON-SSO 사용자 정보를 가져오지 못했습니다.'); logSsoUserInfoShape(data); return data; }; const signHs256 = (payload: Record, secret: string) => { const encode = (value: string) => Buffer.from(value).toString('base64url'); const header = encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const body = encode(JSON.stringify(payload)); const unsigned = `${header}.${body}`; const signature = createHmac('sha256', secret).update(unsigned).digest('base64url'); return `${unsigned}.${signature}`; }; const createSupportSession = (info: OidcUserInfo) => { const jwtSecret = configured('JWT_SECRET'); if (!jwtSecret) throw new Error('JWT_SECRET이 설정되지 않았습니다.'); const subject = asString(info.sub) ?? asString(info.user_id) ?? asString(info.id); const tenantId = 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) { throw new Error( 'SSO 사용자 정보에 sub 또는 tenant_id가 없습니다. SUPPORT_TENANT_ID를 확인하세요.', ); } 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)) { 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], email: asString(info.email), name: asString(info.name), department: asString(info.department), 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, }, jwtSecret, ); }; export const completeLogin = async (req: NextApiRequest, res: NextApiResponse) => { const state = typeof req.query.state === 'string' ? req.query.state : ''; const code = typeof req.query.code === 'string' ? req.query.code : ''; if (!state || !code || !req.cookies[STATE_COOKIE] || state !== req.cookies[STATE_COOKIE]) { throw new Error('BARON-SSO state가 유효하지 않습니다. 로그인부터 다시 시도하세요.'); } const tokens = await exchangeCode(req, code); const idTokenClaims = decodeJwtPayload(tokens.idToken); const accessTokenClaims = decodeJwtPayload(tokens.accessToken); const ssoTokenClaims: Record = { requested_scopes: configured('SSO_SCOPE') || 'openid profile email tenants', token_response_scope: tokens.scope ?? null, token_type: tokens.tokenType ?? null, id_token: idTokenClaims, access_token: accessTokenClaims, }; const rawUserInfo = await getUserInfo(tokens.accessToken); const userInfo = await enrichSsoUserInfo(tokens.accessToken, rawUserInfo); ssoTokenClaims.userinfo = userInfo as Record; const sessionToken = createSupportSession(userInfo); const secure = isSecureRequest(req); const debugOrgContext = { source: 'oauth token claims (decode-only)', requestedScopes: configured('SSO_SCOPE') || 'openid profile email tenants', tokenResponseScope: tokens.scope ?? null, tokenType: tokens.tokenType ?? null, idTokenClaims, accessTokenClaims, }; res.setHeader('Set-Cookie', [ makeCookie(SESSION_COOKIE, JSON.stringify({ accessToken: sessionToken, refreshToken: '' }), { maxAge: SESSION_MAX_AGE, httpOnly: true, secure, }), 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 }), makeCookie(SSO_TOKEN_CLAIMS_COOKIE, '', { maxAge: 0, httpOnly: true, secure }), makeCookie(SSO_TOKEN_CLAIMS_COOKIE_COUNT, '', { maxAge: 0, httpOnly: true, secure, }), ]); };