API 적용 최초 배포
Deploy feedback demo / deploy (push) Failing after 4m26s

This commit is contained in:
root
2026-09-01 17:12:46 +09:00
parent badf5f2c81
commit aaddfc7bfc
44 changed files with 1331 additions and 867 deletions
+223
View File
@@ -0,0 +1,223 @@
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 SESSION_MAX_AGE = 60 * 60;
interface OidcUserInfo {
sub?: unknown;
user_id?: unknown;
id?: unknown;
email?: unknown;
name?: unknown;
department?: unknown;
phone_number?: unknown;
tenant_id?: unknown;
tenantId?: unknown;
tenant_ids?: unknown;
tenantIds?: unknown;
}
const asString = (value: unknown) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
const asStringArray = (value: unknown) =>
Array.isArray(value)
? value.filter((item): item is string => Boolean(asString(item)))
: [];
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;
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 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');
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;
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,
};
};
const getUserInfo = async (accessToken: string): Promise<OidcUserInfo> => {
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 사용자 정보를 가져오지 못했습니다.');
return data;
};
const signHs256 = (payload: Record<string, unknown>, 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) ||
asStringArray(info.tenant_ids ?? info.tenantIds)[0] ||
'';
if (!subject || !tenantId) {
throw new Error(
'SSO 사용자 정보에 sub 또는 tenant_id가 없습니다. SUPPORT_TENANT_ID를 확인하세요.',
);
}
const tenantIds = asStringArray(info.tenant_ids ?? info.tenantIds);
if (!tenantIds.includes(tenantId)) tenantIds.unshift(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: asString(info.phone_number),
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 userInfo = await getUserInfo(tokens.accessToken);
const sessionToken = createSupportSession(userInfo);
const secure = isSecureRequest(req);
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 }),
]);
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 }));
};