@@ -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 }));
|
||||
};
|
||||
Reference in New Issue
Block a user