diff --git a/README.md b/README.md index c90e032..780dd8c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Generic CLI for `eac.zigbang.in` (E-Accounting / UniDocu). -Drives the UniDocu named-service API directly over HTTP. No headless browser at runtime — auth is just the `JSESSIONID` cookie pulled out of your local Chrome profile. +Drives the UniDocu named-service API directly over HTTP. No headless browser at runtime — auth is just the `JSESSIONID` cookie pulled out of your local browser profile (Chrome by default; Comet/Brave/Edge selectable via `EAC_BROWSER`). The CLI intentionally stays **domain-agnostic**: it only knows about EAC data and verbs. Company-/personal-specific policy (e.g. *"자기관리비 환급은 영수증 × 70%"*) belongs in your own shell scripts or runbooks. @@ -16,21 +16,31 @@ brew install zigbang-smarthome/tap/eac-cli curl -fsSL https://github.com/zigbang-smarthome/eac-cli/releases/latest/download/install.sh | sh ``` -Before first use: open Chrome and log in to `https://eac.zigbang.in` at least once so the session cookie lands in your Chrome cookie store. The CLI reads that cookie each run. +Before first use: open your browser and log in to `https://eac.zigbang.in` at least once so the session cookie lands in your browser's cookie store. The CLI reads that cookie each run. ## Authentication -- EAC uses Google SSO + MS Azure AD SAML — login itself can't be automated. The CLI piggybacks on your normal Chrome session. -- Each run, `eac` copies Chrome's cookie DB to tmpdir, decrypts the `JSESSIONID` for `*.zigbang.in` (AES-128-CBC, key from `"Chrome Safe Storage"` Keychain), and pings EAC to confirm the session is alive. -- **If the session is missing or expired**, the CLI opens Chrome at `https://eac.zigbang.in/unidocu/view.do` and prompts: - 1. Finish the Google SSO login in Chrome. - 2. **Quit Chrome (Cmd+Q)** so the new `JSESSIONID` is flushed to disk. Chrome's cookie monster keeps cookies in memory and writes to the SQLite store on a delayed batch schedule — without an explicit quit the CLI may keep reading a stale value. +- EAC uses Google SSO + MS Azure AD SAML — login itself can't be automated. The CLI piggybacks on your normal browser session. +- Each run, `eac` copies the browser's cookie DB to tmpdir, decrypts the `JSESSIONID` for `*.zigbang.in` (AES-128-CBC, key from the `" Safe Storage"` Keychain), and pings EAC to confirm the session is alive. +- **Browser selection** — set `EAC_BROWSER` to choose which Chromium-family browser to read from (default: `chrome`): + + | `EAC_BROWSER` | Browser | + | --- | --- | + | `chrome` (default) | Google Chrome | + | `comet` | Comet (Perplexity) | + | `brave` | Brave | + | `edge` | Microsoft Edge | + + All share the same cookie format on macOS; only the cookie path and Keychain service name differ. Example: `EAC_BROWSER=comet eac voucher list`. +- **If the session is missing or expired**, the CLI opens the selected browser at `https://eac.zigbang.in/unidocu/view.do` and prompts: + 1. Finish the Google SSO login in the browser. + 2. **Quit the browser (Cmd+Q)** so the new `JSESSIONID` is flushed to disk. Chromium keeps cookies in memory and writes to the SQLite store on a delayed batch schedule — without an explicit quit the CLI may keep reading a stale value. 3. Return to the terminal and hit Enter — the CLI re-reads the fresh cookie and continues. - 4. Reopen Chrome normally afterwards. + 4. Reopen the browser normally afterwards. - Tip: turn on Chrome → Settings → On startup → *"Continue where you left off"* so the session cookie survives the Cmd+Q cycle. EAC re-login becomes infrequent (only when the SAP-side session truly expires). + Tip: turn on the browser's *"Continue where you left off"* startup option so the session cookie survives the Cmd+Q cycle. EAC re-login becomes infrequent (only when the SAP-side session truly expires). - macOS may prompt for Keychain access the first time (click *Always Allow*). -- Override: set `EAC_JSESSIONID=` to bypass the keychain entirely and use a cookie sourced elsewhere (e.g. extracted from Playwright/CDP-controlled Chrome). Useful when EAC is open in a non-default Chrome profile or in CI scripts. +- Override: set `EAC_JSESSIONID=` to bypass the keychain entirely and use a cookie sourced elsewhere (e.g. extracted from Playwright/CDP-controlled browser). Useful when EAC is open in a non-default profile or in CI scripts. - Non-TTY environments (CI, piped scripts) error out instead of prompting — pass `EAC_JSESSIONID` explicitly there. --- @@ -186,6 +196,7 @@ eac call ZUNIEFI_4207 --prog DRAFT_0010 --data '{"GRONO":"FI20260000023922"}' - `config show` — 현재 config JSON 프린트 - `config init` — `~/.config/eac/config.json` 없으면 기본값 생성 +- `user.gsber` — 사업영역(business area). **비용센터(`user.kostl`)와 일치**시켜야 한다. EAC는 전표의 차변/대변 사업영역이 다르면 거부한다(`Please input only the same 'Business Area'`, ZUNIEFI_1009). 미설정 시 `K200`으로 fallback하므로, 비용센터가 K300 영역(e.g. `343000`)이면 `gsber: "K300"`을 반드시 설정한다. --- diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4ed5805..6a8886b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,9 +1,13 @@ /** - * macOS Chrome cookie store → JSESSIONID extraction. - * AES-128-CBC decrypt with key derived from "Chrome Safe Storage" keychain password. + * macOS Chromium cookie store → JSESSIONID extraction. + * AES-128-CBC decrypt with key derived from the browser's " Safe Storage" keychain password. + * + * Chromium-family browsers (Chrome, Comet, Brave, Edge) store cookies identically on macOS; + * only the cookie DB path and the keychain service name differ. Select with EAC_BROWSER + * (default: chrome). * * EAC uses Google SSO + MS Azure AD SAML chain. The login itself can't be automated - * (2FA/SSO), so when the session is missing/expired we open Chrome at the EAC URL, + * (2FA/SSO), so when the session is missing/expired we open the browser at the EAC URL, * wait for the user to finish login, then re-read the cookie. */ @@ -15,41 +19,90 @@ import { copyFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { AuthError } from "./errors.ts"; -const CHROME_COOKIES = join(homedir(), "Library/Application Support/Google/Chrome/Default/Cookies"); -const KEYCHAIN_SERVICE = "Chrome Safe Storage"; const HOST_PATTERN = "%zigbang.in%"; const COOKIE_NAME = "JSESSIONID"; -function getChromeMasterKey(): Buffer { +/** + * A Chromium-family browser on macOS. The crypto (PBKDF2 salt=saltysalt/1003/16B → + * AES-128-CBC, 32-byte hash prefix strip) is identical across all of them — only the + * cookie path and keychain service name differ. + */ +interface BrowserProfile { + /** Display name, also the `open -a` target. */ + name: string; + /** Cookies SQLite path. */ + cookies: string; + /** Keychain " Safe Storage" service holding the master key. */ + keychain: string; +} + +const BROWSERS: Record = { + chrome: { + name: "Google Chrome", + cookies: join(homedir(), "Library/Application Support/Google/Chrome/Default/Cookies"), + keychain: "Chrome Safe Storage", + }, + comet: { + name: "Comet", + cookies: join(homedir(), "Library/Application Support/Comet/Default/Cookies"), + keychain: "Comet Safe Storage", + }, + brave: { + name: "Brave Browser", + cookies: join(homedir(), "Library/Application Support/BraveSoftware/Brave-Browser/Default/Cookies"), + keychain: "Brave Safe Storage", + }, + edge: { + name: "Microsoft Edge", + cookies: join(homedir(), "Library/Application Support/Microsoft Edge/Default/Cookies"), + keychain: "Microsoft Edge Safe Storage", + }, +}; + +/** Browser selected via EAC_BROWSER (default: chrome). Throws on an unknown value. */ +function selectedBrowser(): BrowserProfile { + const key = (process.env.EAC_BROWSER ?? "chrome").toLowerCase(); + const b = BROWSERS[key]; + if (!b) { + throw new AuthError( + `Unknown EAC_BROWSER="${key}". Supported: ${Object.keys(BROWSERS).join(", ")}.`, + ); + } + return b; +} + +function getMasterKey(browser: BrowserProfile): Buffer { let out: string; try { - out = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -g 2>&1`, { encoding: "utf-8" }); + out = execSync(`security find-generic-password -s "${browser.keychain}" -g 2>&1`, { encoding: "utf-8" }); } catch (e: any) { - throw new AuthError(`Chrome Safe Storage keychain read failed: ${e?.message ?? e}`); + throw new AuthError(`${browser.keychain} keychain read failed: ${e?.message ?? e}`); } const pw = out.match(/password:\s*"([^"]+)"/)?.[1]; - if (!pw) throw new AuthError("Chrome Safe Storage keychain password not found"); + if (!pw) throw new AuthError(`${browser.keychain} keychain password not found`); return pbkdf2Sync(pw, "saltysalt", 1003, 16, "sha1"); } -function decryptV10(encrypted: Buffer, key: Buffer): string { +function decryptCookie(encrypted: Buffer, key: Buffer): string { if (!encrypted?.length) return ""; - if (encrypted.subarray(0, 3).toString("utf-8") !== "v10") return encrypted.toString("utf-8"); + const prefix = encrypted.subarray(0, 3).toString("utf-8"); + // No version prefix → value is stored unencrypted; return as-is. + if (prefix !== "v10" && prefix !== "v11") return encrypted.toString("utf-8"); const iv = Buffer.from(" ".repeat(16), "utf-8"); const d = createDecipheriv("aes-128-cbc", key, iv); d.setAutoPadding(false); const out = Buffer.concat([d.update(encrypted.subarray(3)), d.final()]); const pad = out[out.length - 1]!; const unpadded = pad > 0 && pad <= 16 ? out.subarray(0, out.length - pad) : out; - // Chrome v10 encrypted values on macOS prefix 32-byte sha256 hash of (host + name); strip it. + // Chromium v10/v11 values on macOS prefix a 32-byte sha256 hash of (host + name); strip it. return unpadded.subarray(32).toString("utf-8"); } -export function extractJSESSIONID(): string { +export function extractJSESSIONID(browser: BrowserProfile = selectedBrowser()): string { const tmp = join(tmpdir(), `eac-${Date.now()}.db`); - copyFileSync(CHROME_COOKIES, tmp); + copyFileSync(browser.cookies, tmp); try { - const key = getChromeMasterKey(); + const key = getMasterKey(browser); const db = new Database(tmp, { readonly: true }); const row = db .query<{ encrypted_value: Buffer }, [string, string]>( @@ -57,8 +110,12 @@ export function extractJSESSIONID(): string { ) .get(HOST_PATTERN, COOKIE_NAME); db.close(); - if (!row) throw new AuthError("JSESSIONID not found in Chrome cookies — log in to eac.zigbang.in in Chrome"); - return decryptV10(Buffer.from(row.encrypted_value), key); + if (!row) { + throw new AuthError( + `JSESSIONID not found in ${browser.name} cookies — log in to eac.zigbang.in in ${browser.name}`, + ); + } + return decryptCookie(Buffer.from(row.encrypted_value), key); } finally { try { unlinkSync(tmp); } catch {} } @@ -82,9 +139,9 @@ export async function isSessionAlive(jsessionid: string): Promise { } } -function openChromeAtEac(): void { - // `open -a "Google Chrome" ` reuses an existing Chrome window/profile. - spawn("open", ["-a", "Google Chrome", EAC_URL], { stdio: "ignore", detached: true }).unref(); +function openBrowserAtEac(browser: BrowserProfile): void { + // `open -a "" ` reuses an existing window/profile. + spawn("open", ["-a", browser.name, EAC_URL], { stdio: "ignore", detached: true }).unref(); } async function waitForEnter(): Promise { @@ -93,7 +150,7 @@ async function waitForEnter(): Promise { process.stderr.write("\n"); } -/** Try to extract the cookie; if missing or expired, open Chrome and wait for the user. */ +/** Try to extract the cookie; if missing or expired, open the browser and wait for the user. */ export async function ensureSession(): Promise { // 1) env override always wins. const envToken = process.env.EAC_JSESSIONID; @@ -102,38 +159,44 @@ export async function ensureSession(): Promise { throw new AuthError("EAC_JSESSIONID is set but the session is not valid (expired or wrong cookie)."); } - // 2) Try Chrome keychain first. Quietly swallow "cookie not present yet" — that's the - // expected state when the user has never logged in to EAC in Chrome. + const browser = selectedBrowser(); + + // 2) Try the browser keychain first. Quietly swallow "cookie not present yet" — that's the + // expected state when the user has never logged in to EAC in this browser. let token = ""; - try { token = extractJSESSIONID(); } catch { token = ""; } + try { token = extractJSESSIONID(browser); } catch { token = ""; } if (token && await isSessionAlive(token)) return token; - // 3) Need a (re-)login. Tell the user, open Chrome, wait, then re-read. + // 3) Need a (re-)login. Tell the user, open the browser, wait, then re-read. if (!process.stdin.isTTY) { - throw new AuthError("EAC session missing/expired and stdin is not a TTY — log in to eac.zigbang.in in Chrome and re-run, or pass EAC_JSESSIONID."); + throw new AuthError( + `EAC session missing/expired and stdin is not a TTY — log in to eac.zigbang.in in ${browser.name} and re-run, or pass EAC_JSESSIONID.`, + ); } process.stderr.write(token - ? "EAC 세션 만료됨. Chrome에서 다시 로그인 필요.\n" - : "EAC 세션 없음. Chrome에서 로그인 필요.\n"); + ? `EAC 세션 만료됨. ${browser.name}에서 다시 로그인 필요.\n` + : `EAC 세션 없음. ${browser.name}에서 로그인 필요.\n`); process.stderr.write([ "", " 순서:", - " 1. 열린 Chrome에서 SSO 로그인을 끝낸다.", - " 2. Chrome을 한 번 종료(Cmd+Q)한다. ← 디스크에 cookie flush", + ` 1. 열린 ${browser.name}에서 SSO 로그인을 끝낸다.`, + ` 2. ${browser.name}을 한 번 종료(Cmd+Q)한다. ← 디스크에 cookie flush`, " (안 끄면 새 JSESSIONID가 메모리에만 남아 CLI가 못 읽는다.)", " 3. 여기로 돌아와 Enter를 누른다.", - " 4. 작업 끝나면 Chrome 다시 켜서 평소대로 사용.", + ` 4. 작업 끝나면 ${browser.name} 다시 켜서 평소대로 사용.`, "", - " 팁: Chrome 설정 > 시작할 때 > '중단한 곳에서 계속하기'를 켜면,", + ` 팁: ${browser.name} 설정 > 시작할 때 > '중단한 곳에서 계속하기'를 켜면,`, " 종료해도 session cookie가 유지돼서 다음번 EAC 재로그인이 줄어든다.", "", ].join("\n")); - openChromeAtEac(); + openBrowserAtEac(browser); await waitForEnter(); - token = extractJSESSIONID(); + token = extractJSESSIONID(browser); if (!await isSessionAlive(token)) { - throw new AuthError("로그인이 확인되지 않았습니다. Chrome에서 eac.zigbang.in에 정상 로그인했는지 확인 후 다시 시도하세요."); + throw new AuthError( + `로그인이 확인되지 않았습니다. ${browser.name}에서 eac.zigbang.in에 정상 로그인했는지 확인 후 다시 시도하세요.`, + ); } return token; } diff --git a/src/lib/config.ts b/src/lib/config.ts index 6fb3324..4a7d6b7 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -53,6 +53,7 @@ export const DEFAULT_CONFIG: EacConfig = { wfIdText: "YG Park (박영걸)", kostl: "226020", kostlText: "Device Engineering", + gsber: "K200", // 사업영역 — 비용센터와 일치시킬 것 (e.g. kostl 343000 → "K300") wfDept: "0000252100", wfDeptText: "Service Engineering", }, diff --git a/src/lib/ops.ts b/src/lib/ops.ts index a76d381..9cac9f8 100644 --- a/src/lib/ops.ts +++ b/src/lib/ops.ts @@ -486,7 +486,7 @@ export async function computeDefaults( const r = await callNS(ctx, "ZUNIEFI_4003", PROG_LEGACY, { BUDAT: budat, BLDAT: bldat, BLART: "KE", LIFNR: user.pernr, LIFNR_TXT: user.pernrName, - BUPLA: "K100", GSBER: "K200", MWSKZ: "T0", + BUPLA: "K100", GSBER: user.gsber ?? "K200", MWSKZ: "T0", AKONT: "21020103", ZTERM: "V123", WRBTR: String(amountWon), WRBTR_SLASH: "", WMWST: "0", BVTYP: "0001", ZFBDT: "", ZFBDT_SLASH: "", @@ -529,7 +529,7 @@ export async function createTempDoc( const commonFi = { BUDAT: budat, BLDAT: bldat, BLART: "KE", LIFNR: user.pernr, LIFNR_TXT: user.pernrName, - BUPLA: "K100", GSBER: "K200", MWSKZ: "T0", + BUPLA: "K100", GSBER: user.gsber ?? "K200", MWSKZ: "T0", AKONT: "21020103", ZTERM: "V123", WRBTR: String(amountWon), WRBTR_SLASH: "", WMWST: "0", BVTYP: "0001", ZFBDT: zfbdt, ZFBDT_SLASH: "", @@ -929,7 +929,7 @@ export async function createCorpCardVoucher( CHARGETOTAL: card.AMOUNT, CHARGETOTAL_Slash: "", WMWST_READ_ONLY: card.TAX, BUDAT: p.budat, BLDAT: bldat, BLART: "KE", - BUPLA: "K100", GSBER: "K200", + BUPLA: "K100", GSBER: user.gsber ?? "K200", WRBTR_SLASH: "", WMWST: card.TAX, ZFBDT_SLASH: "", EMPTY: "", SGTXT: p.sgtxt, diff --git a/src/types/index.ts b/src/types/index.ts index bcc305b..156fee4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,6 +12,12 @@ export interface UserProfile { wfIdText: string; // Full display like "YG Park (박영걸)" kostl: string; // Cost center, e.g. "226020" kostlText: string; // "Device Engineering" + /** + * Business area (사업영역), e.g. "K200" / "K300". MUST match the cost center — + * EAC rejects vouchers whose debit/credit business areas differ (ZUNIEFI_1009). + * Optional for backward compat; falls back to "K200" when absent. + */ + gsber?: string; wfDept: string; // Department code wfDeptText: string; // "Service Engineering" }