665 lines
22 KiB
TypeScript
665 lines
22 KiB
TypeScript
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<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;
|
|
|
|
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<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 사용자 정보를 가져오지 못했습니다.');
|
|
logSsoUserInfoShape(data);
|
|
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) ||
|
|
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<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],
|
|
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<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,
|
|
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,
|
|
}),
|
|
]);
|
|
};
|